mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
feat(local): authorize secret config reconciliation decisions
This commit is contained in:
@@ -26,15 +26,21 @@
|
||||
的有界 content-free 投影与当前 head;`defer`、active Env 无已采纳 Legacy Task、历史 `Configs` 或 target 冲突都继续 manual。publisher 以 no-replace、
|
||||
`0400/0500`、文件/目录 `fsync` 发布 `plan.ndjson` 与 `receipt.json`,覆盖 plan、receipt、terminal seal、head CAS 四个 response-loss 窗口,并只允许
|
||||
`reconciliation_application_planned|reconciliation_automation_applied → reconciliation_secret_config_planned` 的合法单向推进;独立 verify 只读复算,不修复漂移。
|
||||
全部 evidence 不含原 Env name/value、目标 ciphertext/key ID 或 row body。Local Admin 完整测试 `96/96`,Local Owner 完整测试 `287/280/7/0`;
|
||||
后端完整门 `1563/1561/2/0`,18-package clean build/test `2934/2912/22/0`;package boundary、Cluster dependency、Edge import 与十四档
|
||||
第四切片增加独立逐候选 signed decision:只有 ready、非空、无 manual/conflict 的 plan 才能 prepare;每条候选精确选择
|
||||
`apply_active_binding`、`preserve_disabled` 或 `skip`,且 `skip` 必然形成 `manual_required`,不能造成部分 apply。decision 使用与 D-391 相同的强认证
|
||||
reviewer,认证年龄最多 5 分钟、授权生命期最多 30 分钟;独立 HMAC domain 精确绑定 decision/SecretConfig/profile/plan/candidate/application/preparation/
|
||||
prepared head/bundle/reviewer/time。Edge/Standalone decision 与 authorization 文件上限分别为 1/4 MiB,沿用 owner-only、sealed、no-replace 与 `fsync`;
|
||||
lineage 仅允许 `reconciliation_secret_config_planned → reconciliation_secret_config_decision_prepared → reconciliation_secret_config_reviewed`,prepare/commit
|
||||
的 publication response-loss 可精确重放而不重复认证,terminal verify 只读复算 sealed decision、authorization、receipt 与 reviewed head。
|
||||
全部 evidence 不含原 Env name/value、目标 ciphertext/key ID 或 row body。Local Admin 完整测试 `96/96`,Local Owner 完整测试 `295/288/7/0`;
|
||||
后端完整门 `1564/1562/2/0`,18-package clean build/test `2942/2920/22/0`;package boundary、Cluster dependency `62/62`、Edge import 与十四档
|
||||
Local artifact audit 全部 compatible,基础 Edge/Standalone 仍为 `2,611,978 / 2,612,056 bytes`、319 files、58 loaded modules,Owner-only authority
|
||||
没有进入低资源常驻制品。
|
||||
Local Admin 保持 48/47,Local Owner 因两个职责明确的嵌套文件增至 178/177,根目录仍只有一个 50 行 binary entry;workspace 仍为 18 packages、
|
||||
Local Admin 保持 48/47,Local Owner 因六个职责明确的 decision 嵌套文件增至 184/183,根目录仍只有一个 50 行 binary entry;workspace 仍为 18 packages、
|
||||
`singleSourcePackages=[]`、`shallowSourcePackages=[]`,且只允许 exact Secret/Config row planner 导入 inspection subpath。
|
||||
|
||||
D-385~D-388 的 `config.sh`/Keyv/SSH data-directory lineage 与 SQLite `Envs` 保持分离;当前无稳定生产 schema 的历史 `Configs` 表继续 sealed+manual,
|
||||
不猜字段。后续切片必须完成独立 signed decision、Secret envelope + audit + Task/Trigger/dispatch + receipt ledger 的单事务发布、prepared/apply/rollback
|
||||
不猜字段。后续切片必须完成 Secret envelope + audit + Task/Trigger/dispatch + receipt ledger 的单事务发布、prepared/apply/rollback
|
||||
lineage、completion 下一 schema 与备份回收。D-397 apply 只声明 sealed source retained 且 `physicalErasureGuaranteed=false`;明文销毁必须在 restart/
|
||||
readiness、观察窗和 rollback retention 之后另行强认证。Cluster 必须使用 PostgreSQL SERIALIZABLE ledger、外部 KMS/Secret provider 与 HA evidence,
|
||||
不复用 Local SQLite/POSIX authority,也不得把明文写入 PostgreSQL、ConfigMap、Pod env 或 Job command。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-0491:有界 Secret/Config Reconciliation 与任务环境绑定
|
||||
|
||||
- 状态:Proposed(D-397 已实现 Legacy Env inspection、私有有界 row plan 与 durable plan publication;独立 signed decision 和原子 application 尚未完成)
|
||||
- 状态:Proposed(D-397 已实现 Legacy Env inspection、私有有界 row plan、durable plan publication 与独立 signed decision;原子 application 尚未完成)
|
||||
- 日期:2026-08-23
|
||||
- 决策:D-397
|
||||
- 关联:ADR-0073、ADR-0074、ADR-0092、ADR-0094、ADR-0480、ADR-0482、ADR-0483、ADR-0484、ADR-0485、ADR-0486、ADR-0487、ADR-0488、ADR-0490
|
||||
@@ -27,7 +27,7 @@ application plan
|
||||
→ cross-domain completion
|
||||
```
|
||||
|
||||
Secret/Config 不消费 Automation decision 作为自身授权。D-391 把 `secret_and_config` facts 标为 `blocked`,其 review action 只能是 `manual_external|defer`;D-397 专用 adapter 只接收每条 fact 都精确选择 `manual_external` 的决策流,把它重新绑定到同一 sealed bundle、D-392 application plan 与当前 target snapshot,任何 `defer` 都继续失败关闭。后续 application 还必须消费独立的逐候选 signed decision;强认证 User、Project Policy、Secret custody 与 Task mutation authority 都要在写事务前及事务内重新验证。
|
||||
Secret/Config 不消费 Automation decision 作为自身授权。D-391 把 `secret_and_config` facts 标为 `blocked`,其 review action 只能是 `manual_external|defer`;D-397 专用 adapter 只接收每条 fact 都精确选择 `manual_external` 的决策流,把它重新绑定到同一 sealed bundle、D-392 application plan 与当前 target snapshot,任何 `defer` 都继续失败关闭。D-397 使用独立的逐候选 signed decision:只有 `plan.outcome=ready`、候选非空且没有 manual/conflict 才能 prepare;后续 application 必须消费该终态授权。强认证 User、Project Policy、Secret custody 与 Task mutation authority 都要在写事务前及事务内重新验证。
|
||||
|
||||
存在 active Env 时,Automation 必须已经完成,且至少一个经 `QingLong3LegacyAdoptions` 证明的 Legacy Task 可绑定;否则不得用“Secret 已保存”冒充行为迁移。只有停用 Env 的场景可以在 Automation `no_effect` 后做纯保全。
|
||||
|
||||
@@ -68,6 +68,8 @@ Local Owner 使用私有 NDJSON row plan 记录 header、逐行 content-free dis
|
||||
|
||||
durable publisher 固定写入 `<secretConfigRoot>/<secretConfigId>/{plan.ndjson,receipt.json,staging/}`,使用 no-replace publication、`0400/0500` 权限、文件与目录 `fsync`,并覆盖 plan、receipt、terminal seal、head CAS 四个 response-loss 窗口。只有 Automation 无需 adapter 时的 `reconciliation_application_planned`,或 Automation 已完成时的 `reconciliation_automation_applied`,可以单向推进到 `reconciliation_secret_config_planned`;verify 只读复算 plan/receipt/seal/head 绑定,不修复漂移。active Env 若没有至少一条已采纳 Legacy Task ledger 记录仍为 manual;历史 `Configs` 计入 `unadaptedLegacyConfigCount` 并保持 manual。
|
||||
|
||||
独立决策使用私有 NDJSON decision file,每个候选必须按 ordinal/digest 精确选择 `apply_active_binding/reviewed_active_binding`、`preserve_disabled/reviewed_disabled_preservation` 或 `skip/operator_excluded|target_conflict|security_review_required`。任何 `skip` 都把终态 outcome 降为 `manual_required`,不能被 application 当成部分成功;`no_effect` 不需要决策,manual/conflict plan 也不能通过强认证升级。签名授权使用与 D-391 review 相同的强认证 User,认证年龄最多 5 分钟、授权生命期最多 30 分钟,并以独立 HMAC domain 绑定 decision、Secret/Config plan、candidate set、application、preparation、prepared head、sealed bundle、reviewer 与时间。Edge/Standalone decision/authorization 文件分别限制为 1 MiB/4 MiB,沿用 owner-only `0700/0600`、sealed `0500/0400`、no-replace 与 `fsync`。lineage 单向推进 `reconciliation_secret_config_planned → reconciliation_secret_config_decision_prepared → reconciliation_secret_config_reviewed`;prepare/commit 的全部 publication response-loss 窗口都精确重放且不重复认证,terminal verify 只读复算 sealed decision、authorization、receipt 与当前 reviewed head。
|
||||
|
||||
### 4. 原子 application 必须同时完成 custody 与行为绑定
|
||||
|
||||
后续 D-397 application 必须在一个 `BEGIN IMMEDIATE` 事务内完成:
|
||||
@@ -133,6 +135,6 @@ Cluster 不得把 Legacy Env 明文写入 PostgreSQL、ConfigMap、Job command
|
||||
|
||||
## 当前验证与后续门禁
|
||||
|
||||
D-397 当前三切片已经实现并测试:absent、unsupported、Edge over-budget、2.x 顺序、同名连接、disabled preservation、保留前缀、异常状态、effective overflow、candidate digest、content-free diagnostics、私有有界 row plan、目标 Secret 冲突、Automation adoption projection、no-effect/manual outcome、durable no-replace publication、terminal seal、head CAS、四个 response-loss 窗口、只读 verify 与 plan/receipt drift。调用方 visitor 的预算异常保持原始类型,不再被误报为 SQLite 读取失败。Local Admin 完整测试为 96/96;Local Owner 完整测试为 287/280/7/0;后端完整门为 1563/1561/2/0,18-package clean build/test 为 2934/2912/22/0。package boundary、Cluster dependency、Edge import 与十四档 Local artifact audit 全部 compatible;基础 Edge/Standalone 仍为 2,611,978 / 2,612,056 bytes、319 files、58 loaded modules,Owner-only authority 没有进入低资源常驻制品。workspace 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。Local Admin 保持 48/47,Local Owner 随两个职责明确的嵌套文件增至 178/177;根目录仍只有一个 50 行 binary entry,没有新增平铺源文件。依赖隔离门仍只允许 exact Secret/Config row planner 导入 inspection subpath,相邻文件继续被拒绝。
|
||||
D-397 当前四切片已经实现并测试:absent、unsupported、Edge over-budget、2.x 顺序、同名连接、disabled preservation、保留前缀、异常状态、effective overflow、candidate digest、content-free diagnostics、私有有界 row plan、目标 Secret 冲突、Automation adoption projection、no-effect/manual outcome、durable no-replace publication、terminal seal、head CAS、逐候选独立 signed decision、同一强认证 reviewer、decision/authorization byte bound、`skip → manual_required`、prepare/commit response-loss exact replay、只读 terminal verify 与 plan/receipt/authorization/head drift。调用方 visitor 的预算异常保持原始类型,不再被误报为 SQLite 读取失败。Local Admin 完整测试为 96/96;Local Owner 完整测试为 295/288/7/0;后端完整门为 1564/1562/2/0,18-package clean build/test 为 2942/2920/22/0。package boundary、Cluster dependency 62/62、Edge import 与十四档 Local artifact audit 全部 compatible;基础 Edge/Standalone 仍为 2,611,978 / 2,612,056 bytes、319 files、58 loaded modules,Owner-only authority 没有进入低资源常驻制品。workspace 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。Local Admin 保持 48/47,Local Owner 随六个职责明确的 decision 嵌套文件增至 184/183;根目录仍只有一个 50 行 binary entry,没有新增平铺源文件。依赖隔离门只允许 exact Secret/Config row planner 导入 inspection subpath,并只允许 decision authorization/coordinator 导入各自最小密钥、principal 与强认证只读 authority;邻接文件继续被拒绝。
|
||||
|
||||
转为 Accepted 前仍必须完成:独立 signed decision、原子 Secret/Task/Trigger/dispatch publisher、prepared/apply/rollback response-loss、completion schema 演进、18-package/boundary/artifact gates、真实 Edge 空间预算、PostgreSQL HA 与 Cluster Secret provider live gate。
|
||||
转为 Accepted 前仍必须完成:原子 Secret/Task/Trigger/dispatch publisher、prepared/apply/rollback response-loss、completion schema 演进、18-package/boundary/artifact gates、真实 Edge 空间预算、PostgreSQL HA 与 Cluster Secret provider live gate。
|
||||
|
||||
+1
-1
@@ -494,7 +494,7 @@
|
||||
| [ADR-0488](./ADR-0488-cross-domain-reconciliation-completion-fence.md) | 跨领域 Reconciliation 完成围栏与目标重启授权 | Accepted |
|
||||
| [ADR-0489](./ADR-0489-service-manager-completion-restart-lineage.md) | Service Manager 完成围栏重启谱系 | Accepted |
|
||||
| [ADR-0490](./ADR-0490-run-history-terminal-preservation.md) | Run History 终态保全与跨领域完成证明 | Accepted |
|
||||
| [ADR-0491](./ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md) | 有界 Secret/Config Reconciliation 与任务环境绑定 | Proposed(D-397 inspection + durable plan) |
|
||||
| [ADR-0491](./ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md) | 有界 Secret/Config Reconciliation 与任务环境绑定 | Proposed(D-397 inspection + durable plan + signed decision) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ export type LocalCutoverInstanceHeadState =
|
||||
| 'reconciliation_automation_applied'
|
||||
| 'reconciliation_automation_rolled_back'
|
||||
| 'reconciliation_secret_config_planned'
|
||||
| 'reconciliation_secret_config_decision_prepared'
|
||||
| 'reconciliation_secret_config_reviewed'
|
||||
| 'reconciliation_completed'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
@@ -185,6 +187,8 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
|
||||
head.state !== 'reconciliation_automation_applied' &&
|
||||
head.state !== 'reconciliation_automation_rolled_back' &&
|
||||
head.state !== 'reconciliation_secret_config_planned' &&
|
||||
head.state !== 'reconciliation_secret_config_decision_prepared' &&
|
||||
head.state !== 'reconciliation_secret_config_reviewed' &&
|
||||
head.state !== 'reconciliation_completed' &&
|
||||
head.state !== 'rollback_prepared' &&
|
||||
head.state !== 'legacy_restart_requested' &&
|
||||
@@ -367,6 +371,8 @@ export function advanceLocalCutoverInstanceHead(
|
||||
| 'reconciliation_automation_applied'
|
||||
| 'reconciliation_automation_rolled_back'
|
||||
| 'reconciliation_secret_config_planned'
|
||||
| 'reconciliation_secret_config_decision_prepared'
|
||||
| 'reconciliation_secret_config_reviewed'
|
||||
| 'reconciliation_completed'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
@@ -421,6 +427,8 @@ export function advanceLocalCutoverInstanceHead(
|
||||
current.state === 'reconciliation_automation_applied' ||
|
||||
current.state === 'reconciliation_automation_rolled_back' ||
|
||||
current.state === 'reconciliation_secret_config_planned' ||
|
||||
current.state === 'reconciliation_secret_config_decision_prepared' ||
|
||||
current.state === 'reconciliation_secret_config_reviewed' ||
|
||||
current.state === 'reconciliation_completed' ||
|
||||
current.state === 'legacy_restart_requested' ||
|
||||
current.state === 'legacy_running' ||
|
||||
@@ -469,6 +477,10 @@ export function advanceLocalCutoverInstanceHead(
|
||||
(state === 'reconciliation_secret_config_planned' &&
|
||||
(current.state === 'reconciliation_application_planned' ||
|
||||
current.state === 'reconciliation_automation_applied')) ||
|
||||
(state === 'reconciliation_secret_config_decision_prepared' &&
|
||||
current.state === 'reconciliation_secret_config_planned') ||
|
||||
(state === 'reconciliation_secret_config_reviewed' &&
|
||||
current.state === 'reconciliation_secret_config_decision_prepared') ||
|
||||
(state === 'reconciliation_completed' &&
|
||||
(current.state === 'reconciliation_application_planned' ||
|
||||
current.state === 'reconciliation_automation_applied')) ||
|
||||
|
||||
@@ -161,6 +161,15 @@ import {
|
||||
verifyLocalReconciliationSecretConfigPlan,
|
||||
verifyLocalReconciliationSecretConfigPlanCommandFile,
|
||||
} from './reconciliation/application/secret-and-config/coordinator';
|
||||
import {
|
||||
commitLocalReconciliationSecretConfigDecision,
|
||||
commitLocalReconciliationSecretConfigDecisionCommandFile,
|
||||
prepareLocalReconciliationSecretConfigDecision,
|
||||
prepareLocalReconciliationSecretConfigDecisionCommandFile,
|
||||
readLocalReconciliationSecretConfigDecisionTerminal,
|
||||
verifyLocalReconciliationSecretConfigDecision,
|
||||
verifyLocalReconciliationSecretConfigDecisionCommandFile,
|
||||
} from './reconciliation/application/secret-and-config/decisionCoordinator';
|
||||
import {
|
||||
preserveLocalReconciliationRunHistory,
|
||||
preserveLocalReconciliationRunHistoryCommandFile,
|
||||
@@ -218,6 +227,13 @@ export {
|
||||
readLocalReconciliationSecretConfigTerminal,
|
||||
verifyLocalReconciliationSecretConfigPlan,
|
||||
verifyLocalReconciliationSecretConfigPlanCommandFile,
|
||||
prepareLocalReconciliationSecretConfigDecision,
|
||||
prepareLocalReconciliationSecretConfigDecisionCommandFile,
|
||||
commitLocalReconciliationSecretConfigDecision,
|
||||
commitLocalReconciliationSecretConfigDecisionCommandFile,
|
||||
verifyLocalReconciliationSecretConfigDecision,
|
||||
verifyLocalReconciliationSecretConfigDecisionCommandFile,
|
||||
readLocalReconciliationSecretConfigDecisionTerminal,
|
||||
preserveLocalReconciliationRunHistory,
|
||||
preserveLocalReconciliationRunHistoryCommandFile,
|
||||
readLocalReconciliationRunHistoryTerminal,
|
||||
@@ -349,6 +365,27 @@ export {
|
||||
type LocalReconciliationSecretConfigPlanDependencies,
|
||||
type LocalReconciliationSecretConfigTerminal,
|
||||
} from './reconciliation/application/secret-and-config/coordinator';
|
||||
export {
|
||||
normalizeLocalReconciliationSecretConfigDecisionCommitCommand,
|
||||
normalizeLocalReconciliationSecretConfigDecisionPrepareCommand,
|
||||
normalizeLocalReconciliationSecretConfigDecisionVerifyCommand,
|
||||
type LocalReconciliationSecretConfigDecisionCommitCommand,
|
||||
type LocalReconciliationSecretConfigDecisionCommitOptions,
|
||||
type LocalReconciliationSecretConfigDecisionOptions,
|
||||
type LocalReconciliationSecretConfigDecisionPrepareCommand,
|
||||
type LocalReconciliationSecretConfigDecisionPrepareResult,
|
||||
type LocalReconciliationSecretConfigDecisionTerminalResult,
|
||||
type LocalReconciliationSecretConfigDecisionVerifyCommand,
|
||||
} from './reconciliation/application/secret-and-config/decisionContract';
|
||||
export {
|
||||
type LocalReconciliationSecretConfigDecisionDependencies,
|
||||
type LocalReconciliationSecretConfigDecisionTerminal,
|
||||
} from './reconciliation/application/secret-and-config/decisionCoordinator';
|
||||
export {
|
||||
type LocalReconciliationSecretConfigDecision,
|
||||
type LocalReconciliationSecretConfigDecisionDisposition,
|
||||
type LocalReconciliationSecretConfigDecisionReason,
|
||||
} from './reconciliation/application/secret-and-config/decisionFile';
|
||||
export {
|
||||
MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES,
|
||||
MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES,
|
||||
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
rollbackLocalReconciliationAutomationApplyCommandFile,
|
||||
planLocalReconciliationSecretConfigCommandFile,
|
||||
verifyLocalReconciliationSecretConfigPlanCommandFile,
|
||||
prepareLocalReconciliationSecretConfigDecisionCommandFile,
|
||||
commitLocalReconciliationSecretConfigDecisionCommandFile,
|
||||
verifyLocalReconciliationSecretConfigDecisionCommandFile,
|
||||
preserveLocalReconciliationRunHistoryCommandFile,
|
||||
verifyLocalReconciliationRunHistoryCommandFile,
|
||||
completeLocalReconciliationCommandFile,
|
||||
@@ -54,7 +57,7 @@ import {
|
||||
} from './localDeployment';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|reconciliation-automation-apply|reconciliation-automation-apply-verify|reconciliation-automation-apply-rollback|reconciliation-secret-config-plan|reconciliation-secret-config-verify|reconciliation-run-history-preserve|reconciliation-run-history-verify|reconciliation-complete|reconciliation-complete-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|reconciliation-automation-apply|reconciliation-automation-apply-verify|reconciliation-automation-apply-rollback|reconciliation-secret-config-plan|reconciliation-secret-config-verify|reconciliation-secret-config-decision-prepare|reconciliation-secret-config-decision-commit|reconciliation-secret-config-decision-verify|reconciliation-run-history-preserve|reconciliation-run-history-verify|reconciliation-complete|reconciliation-complete-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
@@ -106,6 +109,9 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
argv[0] !== 'reconciliation-automation-apply-rollback' &&
|
||||
argv[0] !== 'reconciliation-secret-config-plan' &&
|
||||
argv[0] !== 'reconciliation-secret-config-verify' &&
|
||||
argv[0] !== 'reconciliation-secret-config-decision-prepare' &&
|
||||
argv[0] !== 'reconciliation-secret-config-decision-commit' &&
|
||||
argv[0] !== 'reconciliation-secret-config-decision-verify' &&
|
||||
argv[0] !== 'reconciliation-run-history-preserve' &&
|
||||
argv[0] !== 'reconciliation-run-history-verify' &&
|
||||
argv[0] !== 'reconciliation-complete' &&
|
||||
@@ -226,6 +232,12 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
? planLocalReconciliationSecretConfigCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-secret-config-verify'
|
||||
? verifyLocalReconciliationSecretConfigPlanCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-secret-config-decision-prepare'
|
||||
? prepareLocalReconciliationSecretConfigDecisionCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-secret-config-decision-commit'
|
||||
? commitLocalReconciliationSecretConfigDecisionCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-secret-config-decision-verify'
|
||||
? verifyLocalReconciliationSecretConfigDecisionCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-run-history-preserve'
|
||||
? preserveLocalReconciliationRunHistoryCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-run-history-verify'
|
||||
|
||||
+720
@@ -0,0 +1,720 @@
|
||||
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { LocalSecretKeyMaterial } from '@qinglong/runtime-core/local-secret';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||
import { LocalReconciliationReviewIssuerKeyringFileProvider } from '../../review/issuerKeyring';
|
||||
import {
|
||||
LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_DISPOSITIONS,
|
||||
normalizeLocalReconciliationSecretConfigDecision,
|
||||
type LocalReconciliationSecretConfigDecision,
|
||||
type LocalReconciliationSecretConfigDecisionDisposition,
|
||||
} from './decisionFile';
|
||||
|
||||
const HEADER_KIND =
|
||||
'qinglong3-local-reconciliation-secret-config-authorization-header';
|
||||
const SIGNATURE_KIND =
|
||||
'qinglong3-local-reconciliation-secret-config-authorization-signature';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
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 UUID_V7_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const MAX_LINE_BYTES = 64 * 1024;
|
||||
const MAX_AUTHENTICATION_AGE_MS = 5 * 60 * 1_000;
|
||||
const MAX_AUTHORIZATION_LIFETIME_MS = 30 * 60 * 1_000;
|
||||
|
||||
export interface LocalReconciliationSecretConfigAuthorizationHeader {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: typeof HEADER_KIND;
|
||||
readonly decisionId: string;
|
||||
readonly secretConfigId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly secretConfigPlanDigest: string;
|
||||
readonly candidateSetDigest: string;
|
||||
readonly applicationPlanDigest: string;
|
||||
readonly preparationDigest: string;
|
||||
readonly preparedHeadDigest: string;
|
||||
readonly bundleDigest: string;
|
||||
readonly bundleFingerprintDigest: string;
|
||||
readonly reviewer: Readonly<SecurityPrincipal>;
|
||||
readonly issuedAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigDecisionCounts {
|
||||
readonly apply_active_binding: number;
|
||||
readonly preserve_disabled: number;
|
||||
readonly skip: number;
|
||||
}
|
||||
|
||||
interface SignatureRecord {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: typeof SIGNATURE_KIND;
|
||||
readonly algorithm: 'hmac-sha256';
|
||||
readonly keyId: string;
|
||||
readonly contentBytes: number;
|
||||
readonly contentDigest: string;
|
||||
readonly decisionCount: number;
|
||||
readonly decisionSetDigest: string;
|
||||
readonly decisionFileDigest: string;
|
||||
readonly dispositionCounts: Readonly<LocalReconciliationSecretConfigDecisionCounts>;
|
||||
readonly authenticationTag: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigAuthorizationEvidence {
|
||||
readonly fileBytes: number;
|
||||
readonly authorizationDigest: string;
|
||||
readonly decisionCount: number;
|
||||
readonly decisionSetDigest: string;
|
||||
readonly decisionFileDigest: string;
|
||||
readonly keyId: string;
|
||||
readonly dispositionCounts: Readonly<LocalReconciliationSecretConfigDecisionCounts>;
|
||||
readonly header: Readonly<LocalReconciliationSecretConfigAuthorizationHeader>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigAuthorizationScope {
|
||||
readonly evidence: Readonly<LocalReconciliationSecretConfigAuthorizationEvidence>;
|
||||
readonly decisions: readonly Readonly<LocalReconciliationSecretConfigDecision>[];
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation secret config authorization ${message}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const actual = Object.keys(record).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function canonicalLine(value: unknown): Buffer {
|
||||
const bytes = Buffer.from(`${JSON.stringify(value)}\n`, 'utf8');
|
||||
if (bytes.byteLength < 3 || bytes.byteLength > MAX_LINE_BYTES + 1) {
|
||||
bytes.fill(0);
|
||||
configurationError('record exceeds its line bound');
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function writeAll(descriptor: number, bytes: Buffer): void {
|
||||
let offset = 0;
|
||||
while (offset < bytes.byteLength) {
|
||||
const written = fs.writeSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.byteLength - offset,
|
||||
);
|
||||
if (written < 1) configurationError('write stalled');
|
||||
offset += written;
|
||||
}
|
||||
}
|
||||
|
||||
function syncDirectory(directory: string): void {
|
||||
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function signingMessage(
|
||||
contentDigest: string,
|
||||
contentBytes: number,
|
||||
decisionFileDigest: string,
|
||||
): Buffer {
|
||||
return Buffer.from(
|
||||
`qinglong3.local-reconciliation-secret-config-authorization.v1\0${contentDigest}\0${contentBytes}\0${decisionFileDigest}`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
function wipe(material: LocalSecretKeyMaterial | null | undefined): void {
|
||||
material?.key.fill(0);
|
||||
}
|
||||
|
||||
function zeroCounts(): Record<
|
||||
LocalReconciliationSecretConfigDecisionDisposition,
|
||||
number
|
||||
> {
|
||||
return {
|
||||
apply_active_binding: 0,
|
||||
preserve_disabled: 0,
|
||||
skip: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCounts(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationSecretConfigDecisionCounts> {
|
||||
const counts = exact(
|
||||
value,
|
||||
[...LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_DISPOSITIONS],
|
||||
'disposition counts',
|
||||
);
|
||||
if (
|
||||
LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_DISPOSITIONS.some(
|
||||
(key) =>
|
||||
!Number.isSafeInteger(counts[key]) || (counts[key] as number) < 0,
|
||||
)
|
||||
) {
|
||||
configurationError('disposition counts are invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
apply_active_binding: counts.apply_active_binding as number,
|
||||
preserve_disabled: counts.preserve_disabled as number,
|
||||
skip: counts.skip as number,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeHeader(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationSecretConfigAuthorizationHeader> {
|
||||
const record = exact(
|
||||
value,
|
||||
[
|
||||
'applicationPlanDigest',
|
||||
'bundleDigest',
|
||||
'bundleFingerprintDigest',
|
||||
'candidateSetDigest',
|
||||
'decisionId',
|
||||
'expiresAtMs',
|
||||
'issuedAtMs',
|
||||
'kind',
|
||||
'preparationDigest',
|
||||
'preparedHeadDigest',
|
||||
'profile',
|
||||
'reviewer',
|
||||
'schemaVersion',
|
||||
'secretConfigId',
|
||||
'secretConfigPlanDigest',
|
||||
],
|
||||
'header',
|
||||
);
|
||||
const issuedAtMs = record.issuedAtMs;
|
||||
const expiresAtMs = record.expiresAtMs;
|
||||
if (
|
||||
!Number.isSafeInteger(issuedAtMs) ||
|
||||
(issuedAtMs as number) < 0 ||
|
||||
!Number.isSafeInteger(expiresAtMs) ||
|
||||
(expiresAtMs as number) <= (issuedAtMs as number) ||
|
||||
(expiresAtMs as number) - (issuedAtMs as number) >
|
||||
MAX_AUTHORIZATION_LIFETIME_MS
|
||||
) {
|
||||
configurationError('authorization lifetime is invalid');
|
||||
}
|
||||
let reviewer: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
reviewer = normalizeSecurityPrincipal(
|
||||
record.reviewer as SecurityPrincipal,
|
||||
issuedAtMs as number,
|
||||
);
|
||||
} catch (error) {
|
||||
return configurationError('reviewer is invalid', error);
|
||||
}
|
||||
if (
|
||||
record.schemaVersion !== 1 ||
|
||||
record.kind !== HEADER_KIND ||
|
||||
typeof record.decisionId !== 'string' ||
|
||||
!UUID_V7_PATTERN.test(record.decisionId) ||
|
||||
typeof record.secretConfigId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(record.secretConfigId) ||
|
||||
(record.profile !== 'edge' && record.profile !== 'standalone') ||
|
||||
![
|
||||
record.secretConfigPlanDigest,
|
||||
record.candidateSetDigest,
|
||||
record.applicationPlanDigest,
|
||||
record.preparationDigest,
|
||||
record.preparedHeadDigest,
|
||||
record.bundleDigest,
|
||||
record.bundleFingerprintDigest,
|
||||
].every(
|
||||
(selected) =>
|
||||
typeof selected === 'string' && DIGEST_PATTERN.test(selected),
|
||||
) ||
|
||||
reviewer.subject.type !== 'user' ||
|
||||
!['hardware', 'local_console', 'multi_factor'].includes(
|
||||
reviewer.assurance,
|
||||
) ||
|
||||
reviewer.authenticatedAtMs > (issuedAtMs as number) ||
|
||||
(issuedAtMs as number) - reviewer.authenticatedAtMs >
|
||||
MAX_AUTHENTICATION_AGE_MS ||
|
||||
reviewer.expiresAtMs < (expiresAtMs as number)
|
||||
) {
|
||||
configurationError('header binding is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...(record as unknown as LocalReconciliationSecretConfigAuthorizationHeader),
|
||||
reviewer,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSignature(value: unknown): Readonly<SignatureRecord> {
|
||||
const record = exact(
|
||||
value,
|
||||
[
|
||||
'algorithm',
|
||||
'authenticationTag',
|
||||
'contentBytes',
|
||||
'contentDigest',
|
||||
'decisionCount',
|
||||
'decisionFileDigest',
|
||||
'decisionSetDigest',
|
||||
'dispositionCounts',
|
||||
'keyId',
|
||||
'kind',
|
||||
'schemaVersion',
|
||||
],
|
||||
'signature',
|
||||
);
|
||||
let tag: Buffer | undefined;
|
||||
try {
|
||||
tag =
|
||||
typeof record.authenticationTag === 'string'
|
||||
? Buffer.from(record.authenticationTag, 'base64url')
|
||||
: Buffer.alloc(0);
|
||||
if (
|
||||
record.schemaVersion !== 1 ||
|
||||
record.kind !== SIGNATURE_KIND ||
|
||||
record.algorithm !== 'hmac-sha256' ||
|
||||
typeof record.keyId !== 'string' ||
|
||||
record.keyId.length < 1 ||
|
||||
!Number.isSafeInteger(record.contentBytes) ||
|
||||
(record.contentBytes as number) < 1 ||
|
||||
typeof record.contentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.contentDigest) ||
|
||||
!Number.isSafeInteger(record.decisionCount) ||
|
||||
(record.decisionCount as number) < 1 ||
|
||||
typeof record.decisionSetDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.decisionSetDigest) ||
|
||||
typeof record.decisionFileDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.decisionFileDigest) ||
|
||||
typeof record.authenticationTag !== 'string' ||
|
||||
tag.byteLength !== 32 ||
|
||||
tag.toString('base64url') !== record.authenticationTag
|
||||
) {
|
||||
configurationError('signature record is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...(record as unknown as SignatureRecord),
|
||||
dispositionCounts: normalizeCounts(record.dispositionCounts),
|
||||
});
|
||||
} finally {
|
||||
tag?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildLocalReconciliationSecretConfigAuthorizationHeader(
|
||||
value: Omit<
|
||||
LocalReconciliationSecretConfigAuthorizationHeader,
|
||||
'schemaVersion' | 'kind'
|
||||
>,
|
||||
): Readonly<LocalReconciliationSecretConfigAuthorizationHeader> {
|
||||
return normalizeHeader({
|
||||
schemaVersion: 1,
|
||||
kind: HEADER_KIND,
|
||||
...value,
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishLocalReconciliationSecretConfigAuthorization(
|
||||
options: Readonly<{
|
||||
targetPath: string;
|
||||
stagePath: string;
|
||||
maxBytes: number;
|
||||
header: Readonly<LocalReconciliationSecretConfigAuthorizationHeader>;
|
||||
keyProvider: LocalReconciliationReviewIssuerKeyringFileProvider;
|
||||
writeDecisions: (
|
||||
append: (
|
||||
decision: Readonly<LocalReconciliationSecretConfigDecision>,
|
||||
) => void,
|
||||
) => Readonly<{
|
||||
decisionFileDigest: string;
|
||||
confirmDecisionFileAuthority(): void;
|
||||
}>;
|
||||
confirmAuthority(): void | Promise<void>;
|
||||
}>,
|
||||
): Promise<Readonly<LocalReconciliationSecretConfigAuthorizationEvidence>> {
|
||||
await options.confirmAuthority();
|
||||
const keyringBefore = options.keyProvider.inspect();
|
||||
let material: LocalSecretKeyMaterial | undefined;
|
||||
let descriptor: number | undefined;
|
||||
let created = false;
|
||||
try {
|
||||
material = await options.keyProvider.active();
|
||||
if (
|
||||
material.keyId !== keyringBefore.activeKeyId ||
|
||||
material.key.byteLength !== 32
|
||||
) {
|
||||
configurationError('active issuer key drifted');
|
||||
}
|
||||
descriptor = fs.openSync(
|
||||
options.stagePath,
|
||||
fs.constants.O_CREAT |
|
||||
fs.constants.O_EXCL |
|
||||
fs.constants.O_WRONLY |
|
||||
(fs.constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
created = true;
|
||||
fs.fchmodSync(descriptor, 0o600);
|
||||
const fileHash = createHash('sha256');
|
||||
const contentHash = createHash('sha256');
|
||||
const decisionHash = createHash('sha256').update(
|
||||
'qinglong3.local-reconciliation-secret-config-decision-set.v1\0',
|
||||
);
|
||||
let fileBytes = 0;
|
||||
let contentBytes = 0;
|
||||
let decisionCount = 0;
|
||||
const dispositionCounts = zeroCounts();
|
||||
const writeContent = (value: unknown, selectedDecision: boolean): void => {
|
||||
const line = canonicalLine(value);
|
||||
try {
|
||||
if (fileBytes + line.byteLength > options.maxBytes) {
|
||||
configurationError('exceeds profile byte budget');
|
||||
}
|
||||
writeAll(descriptor!, line);
|
||||
fileHash.update(line);
|
||||
contentHash.update(line);
|
||||
contentBytes += line.byteLength;
|
||||
fileBytes += line.byteLength;
|
||||
if (selectedDecision) decisionHash.update(line);
|
||||
} finally {
|
||||
line.fill(0);
|
||||
}
|
||||
};
|
||||
writeContent(options.header, false);
|
||||
const decisionFile = options.writeDecisions((value) => {
|
||||
const selected = normalizeLocalReconciliationSecretConfigDecision(value);
|
||||
writeContent(selected, true);
|
||||
decisionCount += 1;
|
||||
dispositionCounts[selected.disposition] += 1;
|
||||
});
|
||||
if (
|
||||
decisionCount < 1 ||
|
||||
!DIGEST_PATTERN.test(decisionFile.decisionFileDigest)
|
||||
) {
|
||||
configurationError('decision file evidence is invalid');
|
||||
}
|
||||
await options.confirmAuthority();
|
||||
decisionFile.confirmDecisionFileAuthority();
|
||||
const keyringAfter = options.keyProvider.inspect();
|
||||
if (
|
||||
keyringAfter.keyringDigest !== keyringBefore.keyringDigest ||
|
||||
keyringAfter.activeKeyId !== material.keyId
|
||||
) {
|
||||
configurationError('issuer authority changed while signing');
|
||||
}
|
||||
const contentDigest = contentHash.digest('hex');
|
||||
const decisionSetDigest = decisionHash.digest('hex');
|
||||
const message = signingMessage(
|
||||
contentDigest,
|
||||
contentBytes,
|
||||
decisionFile.decisionFileDigest,
|
||||
);
|
||||
const authenticationTag = createHmac('sha256', material.key)
|
||||
.update(message)
|
||||
.digest('base64url');
|
||||
message.fill(0);
|
||||
const signature: Readonly<SignatureRecord> = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
kind: SIGNATURE_KIND,
|
||||
algorithm: 'hmac-sha256',
|
||||
keyId: material.keyId,
|
||||
contentBytes,
|
||||
contentDigest,
|
||||
decisionCount,
|
||||
decisionSetDigest,
|
||||
decisionFileDigest: decisionFile.decisionFileDigest,
|
||||
dispositionCounts: Object.freeze({ ...dispositionCounts }),
|
||||
authenticationTag,
|
||||
});
|
||||
const signatureLine = canonicalLine(signature);
|
||||
try {
|
||||
if (fileBytes + signatureLine.byteLength > options.maxBytes) {
|
||||
configurationError('exceeds profile byte budget');
|
||||
}
|
||||
writeAll(descriptor, signatureLine);
|
||||
fileHash.update(signatureLine);
|
||||
fileBytes += signatureLine.byteLength;
|
||||
} finally {
|
||||
signatureLine.fill(0);
|
||||
}
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
await options.confirmAuthority();
|
||||
decisionFile.confirmDecisionFileAuthority();
|
||||
const keyringSigned = options.keyProvider.inspect();
|
||||
if (
|
||||
keyringSigned.keyringDigest !== keyringBefore.keyringDigest ||
|
||||
keyringSigned.activeKeyId !== material.keyId
|
||||
) {
|
||||
configurationError('issuer authority changed after signing');
|
||||
}
|
||||
try {
|
||||
fs.linkSync(options.stagePath, options.targetPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
||||
configurationError('target already exists');
|
||||
}
|
||||
syncDirectory(path.dirname(options.targetPath));
|
||||
fs.unlinkSync(options.stagePath);
|
||||
created = false;
|
||||
syncDirectory(path.dirname(options.stagePath));
|
||||
return Object.freeze({
|
||||
fileBytes,
|
||||
authorizationDigest: fileHash.digest('hex'),
|
||||
decisionCount,
|
||||
decisionSetDigest,
|
||||
decisionFileDigest: decisionFile.decisionFileDigest,
|
||||
keyId: material.keyId,
|
||||
dispositionCounts: Object.freeze({ ...dispositionCounts }),
|
||||
header: options.header,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return configurationError('cannot be published', error);
|
||||
} finally {
|
||||
wipe(material);
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
if (created) {
|
||||
try {
|
||||
fs.unlinkSync(options.stagePath);
|
||||
} catch {
|
||||
// A complete owner-only stage is recoverable; partial stages fail closed.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyLocalReconciliationSecretConfigAuthorization(
|
||||
filePath: string,
|
||||
options: Readonly<{
|
||||
maxBytes: number;
|
||||
allowedModes: readonly number[];
|
||||
keyProvider: LocalReconciliationReviewIssuerKeyringFileProvider;
|
||||
expected: Readonly<{
|
||||
decisionId: string;
|
||||
secretConfigId: string;
|
||||
profile: 'edge' | 'standalone';
|
||||
secretConfigPlanDigest: string;
|
||||
candidateSetDigest: string;
|
||||
applicationPlanDigest: string;
|
||||
preparationDigest: string;
|
||||
preparedHeadDigest: string;
|
||||
bundleDigest: string;
|
||||
bundleFingerprintDigest: string;
|
||||
decisionFileDigest: string;
|
||||
}>;
|
||||
}>,
|
||||
): Promise<Readonly<LocalReconciliationSecretConfigAuthorizationScope>> {
|
||||
const uid = process.getuid?.();
|
||||
if (!Number.isSafeInteger(uid) || uid !== process.geteuid?.()) {
|
||||
configurationError('requires stable POSIX identity');
|
||||
}
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== uid ||
|
||||
!options.allowedModes.includes(Number(before.mode) & 0o777) ||
|
||||
before.nlink !== 1n ||
|
||||
before.size < 2n ||
|
||||
before.size > BigInt(options.maxBytes)
|
||||
) {
|
||||
configurationError('file identity or size is invalid');
|
||||
}
|
||||
let descriptor: number | undefined;
|
||||
let bytes: Buffer | undefined;
|
||||
let material: LocalSecretKeyMaterial | null | undefined;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.size !== before.size ||
|
||||
opened.mtimeNs !== before.mtimeNs ||
|
||||
opened.ctimeNs !== before.ctimeNs
|
||||
) {
|
||||
configurationError('file changed while opening');
|
||||
}
|
||||
bytes = fs.readFileSync(descriptor);
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
after.dev !== opened.dev ||
|
||||
after.ino !== opened.ino ||
|
||||
after.size !== opened.size ||
|
||||
after.mtimeNs !== opened.mtimeNs ||
|
||||
after.ctimeNs !== opened.ctimeNs
|
||||
) {
|
||||
configurationError('file changed while reading');
|
||||
}
|
||||
const linked = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
linked.dev !== opened.dev ||
|
||||
linked.ino !== opened.ino ||
|
||||
linked.size !== opened.size ||
|
||||
linked.mtimeNs !== opened.mtimeNs ||
|
||||
linked.ctimeNs !== opened.ctimeNs
|
||||
) {
|
||||
configurationError('file path changed while reading');
|
||||
}
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
} catch (error) {
|
||||
return configurationError('file is not UTF-8', error);
|
||||
}
|
||||
const framed = decoded.split('\n');
|
||||
if (framed.at(-1) !== '') configurationError('must end with newline');
|
||||
framed.pop();
|
||||
if (framed.length < 3) configurationError('file is incomplete');
|
||||
const parsed = framed.map((line, index) => {
|
||||
if (
|
||||
Buffer.byteLength(line, 'utf8') < 2 ||
|
||||
Buffer.byteLength(line, 'utf8') > MAX_LINE_BYTES
|
||||
) {
|
||||
configurationError(`record ${index + 1} exceeds its line bound`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(line) as unknown;
|
||||
} catch (error) {
|
||||
return configurationError(`record ${index + 1} is not JSON`, error);
|
||||
}
|
||||
});
|
||||
const selectedHeader = normalizeHeader(parsed[0]);
|
||||
const signature = normalizeSignature(parsed.at(-1));
|
||||
const decisions = Object.freeze(
|
||||
parsed
|
||||
.slice(1, -1)
|
||||
.map((value) =>
|
||||
normalizeLocalReconciliationSecretConfigDecision(value),
|
||||
),
|
||||
);
|
||||
const expected = options.expected;
|
||||
if (
|
||||
selectedHeader.decisionId !== expected.decisionId ||
|
||||
selectedHeader.secretConfigId !== expected.secretConfigId ||
|
||||
selectedHeader.profile !== expected.profile ||
|
||||
selectedHeader.secretConfigPlanDigest !==
|
||||
expected.secretConfigPlanDigest ||
|
||||
selectedHeader.candidateSetDigest !== expected.candidateSetDigest ||
|
||||
selectedHeader.applicationPlanDigest !== expected.applicationPlanDigest ||
|
||||
selectedHeader.preparationDigest !== expected.preparationDigest ||
|
||||
selectedHeader.preparedHeadDigest !== expected.preparedHeadDigest ||
|
||||
selectedHeader.bundleDigest !== expected.bundleDigest ||
|
||||
selectedHeader.bundleFingerprintDigest !==
|
||||
expected.bundleFingerprintDigest ||
|
||||
signature.decisionFileDigest !== expected.decisionFileDigest ||
|
||||
signature.decisionCount !== decisions.length
|
||||
) {
|
||||
configurationError('authorization binding drifted');
|
||||
}
|
||||
const contentLines = framed.slice(0, -1).map((line) => `${line}\n`);
|
||||
const content = Buffer.from(contentLines.join(''), 'utf8');
|
||||
const decisionBytes = Buffer.from(
|
||||
framed
|
||||
.slice(1, -1)
|
||||
.map((line) => `${line}\n`)
|
||||
.join(''),
|
||||
'utf8',
|
||||
);
|
||||
try {
|
||||
const counts = zeroCounts();
|
||||
for (const selected of decisions) counts[selected.disposition] += 1;
|
||||
if (
|
||||
signature.contentBytes !== content.byteLength ||
|
||||
signature.contentDigest !==
|
||||
createHash('sha256').update(content).digest('hex') ||
|
||||
signature.decisionSetDigest !==
|
||||
createHash('sha256')
|
||||
.update(
|
||||
'qinglong3.local-reconciliation-secret-config-decision-set.v1\0',
|
||||
)
|
||||
.update(decisionBytes)
|
||||
.digest('hex') ||
|
||||
JSON.stringify(signature.dispositionCounts) !== JSON.stringify(counts)
|
||||
) {
|
||||
configurationError('authorization summary drifted');
|
||||
}
|
||||
material = await options.keyProvider.resolve(signature.keyId);
|
||||
if (!material || material.key.byteLength !== 32) {
|
||||
configurationError('issuer key is unavailable');
|
||||
}
|
||||
const message = signingMessage(
|
||||
signature.contentDigest,
|
||||
signature.contentBytes,
|
||||
signature.decisionFileDigest,
|
||||
);
|
||||
const expectedTag = createHmac('sha256', material.key)
|
||||
.update(message)
|
||||
.digest();
|
||||
const suppliedTag = Buffer.from(signature.authenticationTag, 'base64url');
|
||||
message.fill(0);
|
||||
try {
|
||||
if (
|
||||
suppliedTag.byteLength !== expectedTag.byteLength ||
|
||||
!timingSafeEqual(suppliedTag, expectedTag)
|
||||
) {
|
||||
configurationError('authentication tag is invalid');
|
||||
}
|
||||
} finally {
|
||||
expectedTag.fill(0);
|
||||
suppliedTag.fill(0);
|
||||
}
|
||||
return Object.freeze({
|
||||
evidence: Object.freeze({
|
||||
fileBytes: bytes.byteLength,
|
||||
authorizationDigest: createHash('sha256').update(bytes).digest('hex'),
|
||||
decisionCount: decisions.length,
|
||||
decisionSetDigest: signature.decisionSetDigest,
|
||||
decisionFileDigest: signature.decisionFileDigest,
|
||||
keyId: signature.keyId,
|
||||
dispositionCounts: signature.dispositionCounts,
|
||||
header: selectedHeader,
|
||||
}),
|
||||
decisions,
|
||||
});
|
||||
} finally {
|
||||
content.fill(0);
|
||||
decisionBytes.fill(0);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return configurationError('cannot be verified', error);
|
||||
} finally {
|
||||
wipe(material);
|
||||
bytes?.fill(0);
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
+443
@@ -0,0 +1,443 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { currentIdentity } from '../../../foundation/contract';
|
||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
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 UUID_V7_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
export const MAX_LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_LIFETIME_MS =
|
||||
30 * 60 * 1_000;
|
||||
|
||||
export interface LocalReconciliationSecretConfigDecisionOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly applicationRoot: string;
|
||||
readonly secretConfigRoot: string;
|
||||
readonly secretConfigDecisionRoot: string;
|
||||
readonly allowRootService: boolean;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigDecisionPrepareCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.secret-config.decision.prepare';
|
||||
readonly options: Readonly<LocalReconciliationSecretConfigDecisionOptions>;
|
||||
readonly request: Readonly<{
|
||||
decisionId: string;
|
||||
secretConfigId: string;
|
||||
expectedSecretConfigPlanDigest: string;
|
||||
expectedHeadDigest: string;
|
||||
preparedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigDecisionCommitOptions
|
||||
extends LocalReconciliationSecretConfigDecisionOptions {
|
||||
readonly targetDatabasePath: string;
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigDecisionCommitCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.secret-config.decision.commit';
|
||||
readonly options: Readonly<LocalReconciliationSecretConfigDecisionCommitOptions>;
|
||||
readonly request: Readonly<{
|
||||
decisionId: string;
|
||||
secretConfigId: string;
|
||||
expectedPreparationDigest: string;
|
||||
expectedHeadDigest: string;
|
||||
decisionFilePath: string;
|
||||
committedAtMs: number;
|
||||
authorizationLifetimeMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigDecisionVerifyCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.secret-config.decision.verify';
|
||||
readonly options: Readonly<LocalReconciliationSecretConfigDecisionOptions>;
|
||||
readonly request: Readonly<{
|
||||
decisionId: string;
|
||||
secretConfigId: string;
|
||||
expectedDecisionDigest: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigDecisionPrepareResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.secret-config.decision.prepare';
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: 'reconciliation_secret_config_decision_prepared';
|
||||
readonly decisionId: string;
|
||||
readonly secretConfigId: string;
|
||||
readonly preparationDigest: string;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigDecisionTerminalResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation:
|
||||
| 'local.deployment.reconciliation.secret-config.decision.commit'
|
||||
| 'local.deployment.reconciliation.secret-config.decision.verify';
|
||||
readonly status: 'prepared' | 'existing' | 'verified';
|
||||
readonly state: 'reconciliation_secret_config_reviewed';
|
||||
readonly decisionId: string;
|
||||
readonly secretConfigId: string;
|
||||
readonly decisionDigest: string;
|
||||
readonly signedDecisionSetDigest: string;
|
||||
readonly candidateCount: number;
|
||||
readonly applyBindingCount: number;
|
||||
readonly preserveDisabledCount: number;
|
||||
readonly skippedCount: number;
|
||||
readonly outcome: 'ready' | 'manual_required';
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation secret config decision ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function safePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
value.includes('//') ||
|
||||
!SAFE_PATH_PATTERN.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
configurationError(`${label} must be a safe non-root absolute path`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function overlaps(left: string, right: string): boolean {
|
||||
const relative = path.relative(left, right);
|
||||
return (
|
||||
relative === '' ||
|
||||
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function descendant(root: string, candidate: string, label: string): void {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
configurationError(`${label} must be below deploymentRoot`);
|
||||
}
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
configurationError(`${label} must be a SHA-256 digest`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function secretConfigId(value: unknown): string {
|
||||
if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) {
|
||||
configurationError('secretConfigId must be a lowercase UUID v4');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function decisionId(value: unknown): string {
|
||||
if (typeof value !== 'string' || !UUID_V7_PATTERN.test(value)) {
|
||||
configurationError('decisionId must be a lowercase UUID v7');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function baseOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationSecretConfigDecisionOptions> {
|
||||
const options = object(value, 'options');
|
||||
const keys = [
|
||||
'allowRootService',
|
||||
'applicationRoot',
|
||||
'deploymentRoot',
|
||||
'secretConfigDecisionRoot',
|
||||
'secretConfigRoot',
|
||||
];
|
||||
exact(options, keys, 'options');
|
||||
const identity = currentIdentity();
|
||||
if (
|
||||
typeof options.allowRootService !== 'boolean' ||
|
||||
(identity.uid === 0) !== options.allowRootService
|
||||
) {
|
||||
configurationError('command identity is invalid');
|
||||
}
|
||||
const roots = keys
|
||||
.filter((key) => key !== 'allowRootService')
|
||||
.map((key) => safePath(options[key], key));
|
||||
for (let left = 0; left < roots.length; left += 1) {
|
||||
for (let right = left + 1; right < roots.length; right += 1) {
|
||||
if (
|
||||
overlaps(roots[left]!, roots[right]!) ||
|
||||
overlaps(roots[right]!, roots[left]!)
|
||||
) {
|
||||
configurationError('authority roots overlap');
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
deploymentRoot: safePath(options.deploymentRoot, 'deploymentRoot'),
|
||||
applicationRoot: safePath(options.applicationRoot, 'applicationRoot'),
|
||||
secretConfigRoot: safePath(options.secretConfigRoot, 'secretConfigRoot'),
|
||||
secretConfigDecisionRoot: safePath(
|
||||
options.secretConfigDecisionRoot,
|
||||
'secretConfigDecisionRoot',
|
||||
),
|
||||
allowRootService: options.allowRootService as boolean,
|
||||
});
|
||||
}
|
||||
|
||||
function command(value: unknown, operation: string) {
|
||||
const selected = object(value, 'command');
|
||||
exact(
|
||||
selected,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
if (selected.schemaVersion !== 1 || selected.operation !== operation) {
|
||||
configurationError('command version or operation is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
options: selected.options,
|
||||
request: object(selected.request, 'request'),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationSecretConfigDecisionPrepareCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationSecretConfigDecisionPrepareCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.secret-config.decision.prepare',
|
||||
);
|
||||
exact(
|
||||
selected.request,
|
||||
[
|
||||
'decisionId',
|
||||
'expectedHeadDigest',
|
||||
'expectedSecretConfigPlanDigest',
|
||||
'preparedAtMs',
|
||||
'secretConfigId',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(selected.request.preparedAtMs) ||
|
||||
(selected.request.preparedAtMs as number) < 0
|
||||
) {
|
||||
configurationError('preparedAtMs is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.secret-config.decision.prepare',
|
||||
options: baseOptions(selected.options),
|
||||
request: Object.freeze({
|
||||
decisionId: decisionId(selected.request.decisionId),
|
||||
secretConfigId: secretConfigId(selected.request.secretConfigId),
|
||||
expectedSecretConfigPlanDigest: digest(
|
||||
selected.request.expectedSecretConfigPlanDigest,
|
||||
'expectedSecretConfigPlanDigest',
|
||||
),
|
||||
expectedHeadDigest: digest(
|
||||
selected.request.expectedHeadDigest,
|
||||
'expectedHeadDigest',
|
||||
),
|
||||
preparedAtMs: selected.request.preparedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationSecretConfigDecisionCommitCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationSecretConfigDecisionCommitCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.secret-config.decision.commit',
|
||||
);
|
||||
const options = object(selected.options, 'options');
|
||||
const hasBusyTimeout = Object.hasOwn(options, 'busyTimeoutMs');
|
||||
exact(
|
||||
options,
|
||||
[
|
||||
'allowRootService',
|
||||
'applicationRoot',
|
||||
'credentialFilePath',
|
||||
'deploymentRoot',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'secretConfigDecisionRoot',
|
||||
'secretConfigRoot',
|
||||
'targetDatabasePath',
|
||||
...(hasBusyTimeout ? ['busyTimeoutMs'] : []),
|
||||
],
|
||||
'options',
|
||||
);
|
||||
const base = baseOptions({
|
||||
allowRootService: options.allowRootService,
|
||||
applicationRoot: options.applicationRoot,
|
||||
deploymentRoot: options.deploymentRoot,
|
||||
secretConfigDecisionRoot: options.secretConfigDecisionRoot,
|
||||
secretConfigRoot: options.secretConfigRoot,
|
||||
});
|
||||
const targetDatabasePath = safePath(
|
||||
options.targetDatabasePath,
|
||||
'targetDatabasePath',
|
||||
);
|
||||
const ownerPepperKeyringDirectory = safePath(
|
||||
options.ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
);
|
||||
const credentialFilePath = safePath(
|
||||
options.credentialFilePath,
|
||||
'credentialFilePath',
|
||||
);
|
||||
for (const [candidate, label] of [
|
||||
[ownerPepperKeyringDirectory, 'ownerPepperKeyringDirectory'],
|
||||
[credentialFilePath, 'credentialFilePath'],
|
||||
] as const) {
|
||||
descendant(base.deploymentRoot, candidate, label);
|
||||
}
|
||||
if (
|
||||
options.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(options.busyTimeoutMs) ||
|
||||
(options.busyTimeoutMs as number) < 1 ||
|
||||
(options.busyTimeoutMs as number) > 60_000)
|
||||
) {
|
||||
configurationError('busyTimeoutMs is invalid');
|
||||
}
|
||||
exact(
|
||||
selected.request,
|
||||
[
|
||||
'authorizationLifetimeMs',
|
||||
'committedAtMs',
|
||||
'decisionFilePath',
|
||||
'decisionId',
|
||||
'expectedHeadDigest',
|
||||
'expectedPreparationDigest',
|
||||
'secretConfigId',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
const decisionFilePath = safePath(
|
||||
selected.request.decisionFilePath,
|
||||
'decisionFilePath',
|
||||
);
|
||||
if (
|
||||
[
|
||||
base.deploymentRoot,
|
||||
base.applicationRoot,
|
||||
base.secretConfigRoot,
|
||||
base.secretConfigDecisionRoot,
|
||||
].some(
|
||||
(root) =>
|
||||
overlaps(root, decisionFilePath) || overlaps(decisionFilePath, root),
|
||||
)
|
||||
) {
|
||||
configurationError('decisionFilePath overlaps an authority root');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(selected.request.committedAtMs) ||
|
||||
(selected.request.committedAtMs as number) < 0 ||
|
||||
!Number.isSafeInteger(selected.request.authorizationLifetimeMs) ||
|
||||
(selected.request.authorizationLifetimeMs as number) < 1 ||
|
||||
(selected.request.authorizationLifetimeMs as number) >
|
||||
MAX_LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_LIFETIME_MS
|
||||
) {
|
||||
configurationError('decision timestamp or lifetime is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.secret-config.decision.commit',
|
||||
options: Object.freeze({
|
||||
...base,
|
||||
targetDatabasePath,
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
...(options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: options.busyTimeoutMs as number }),
|
||||
}),
|
||||
request: Object.freeze({
|
||||
decisionId: decisionId(selected.request.decisionId),
|
||||
secretConfigId: secretConfigId(selected.request.secretConfigId),
|
||||
expectedPreparationDigest: digest(
|
||||
selected.request.expectedPreparationDigest,
|
||||
'expectedPreparationDigest',
|
||||
),
|
||||
expectedHeadDigest: digest(
|
||||
selected.request.expectedHeadDigest,
|
||||
'expectedHeadDigest',
|
||||
),
|
||||
decisionFilePath,
|
||||
committedAtMs: selected.request.committedAtMs as number,
|
||||
authorizationLifetimeMs: selected.request
|
||||
.authorizationLifetimeMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationSecretConfigDecisionVerifyCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationSecretConfigDecisionVerifyCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.secret-config.decision.verify',
|
||||
);
|
||||
exact(
|
||||
selected.request,
|
||||
['decisionId', 'expectedDecisionDigest', 'secretConfigId'],
|
||||
'request',
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.secret-config.decision.verify',
|
||||
options: baseOptions(selected.options),
|
||||
request: Object.freeze({
|
||||
decisionId: decisionId(selected.request.decisionId),
|
||||
secretConfigId: secretConfigId(selected.request.secretConfigId),
|
||||
expectedDecisionDigest: digest(
|
||||
selected.request.expectedDecisionDigest,
|
||||
'expectedDecisionDigest',
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
+1217
File diff suppressed because it is too large
Load Diff
+277
@@ -0,0 +1,277 @@
|
||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||
import { cutoverDigest } from '../../../cutover/targetEvidence';
|
||||
import {
|
||||
normalizeLocalReconciliationSecretConfigDecisionPrepareCommand,
|
||||
type LocalReconciliationSecretConfigDecisionPrepareCommand,
|
||||
} from './decisionContract';
|
||||
|
||||
const INTENT_SCHEMA =
|
||||
'qinglong3-local-reconciliation-secret-config-decision-intent';
|
||||
const RECEIPT_SCHEMA =
|
||||
'qinglong3-local-reconciliation-secret-config-decision-receipt';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface LocalReconciliationSecretConfigDecisionIntent {
|
||||
readonly schema: typeof INTENT_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly command: Readonly<LocalReconciliationSecretConfigDecisionPrepareCommand>;
|
||||
readonly applicationId: string;
|
||||
readonly applicationPlanDigest: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly projectId: string;
|
||||
readonly secretConfigPlanDigest: string;
|
||||
readonly candidateSetDigest: string;
|
||||
readonly bundleDigest: string;
|
||||
readonly bundleFingerprintDigest: string;
|
||||
readonly instanceId: string;
|
||||
readonly cutoverId: string;
|
||||
readonly activationDigest: string;
|
||||
readonly generation: number;
|
||||
readonly preparationDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigDecisionReceipt {
|
||||
readonly schema: typeof RECEIPT_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'reconciliation_secret_config_reviewed';
|
||||
readonly decisionId: string;
|
||||
readonly secretConfigId: string;
|
||||
readonly secretConfigPlanDigest: string;
|
||||
readonly candidateSetDigest: string;
|
||||
readonly applicationPlanDigest: string;
|
||||
readonly preparedHeadDigest: string;
|
||||
readonly authorizationDigest: string;
|
||||
readonly signedDecisionSetDigest: string;
|
||||
readonly decisionFileDigest: string;
|
||||
readonly reviewerDigest: string;
|
||||
readonly candidateCount: number;
|
||||
readonly applyBindingCount: number;
|
||||
readonly preserveDisabledCount: number;
|
||||
readonly skippedCount: number;
|
||||
readonly outcome: 'ready' | 'manual_required';
|
||||
readonly issuedAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
readonly decisionDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation secret config decision evidence ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const actual = Object.keys(record).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
export function buildLocalReconciliationSecretConfigDecisionIntent(
|
||||
input: Omit<
|
||||
LocalReconciliationSecretConfigDecisionIntent,
|
||||
'schema' | 'schemaVersion' | 'preparationDigest'
|
||||
>,
|
||||
): Readonly<LocalReconciliationSecretConfigDecisionIntent> {
|
||||
const payload = Object.freeze({
|
||||
schema: INTENT_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
...input,
|
||||
});
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
preparationDigest: cutoverDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationSecretConfigDecisionIntent(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationSecretConfigDecisionIntent> {
|
||||
const intent = exact(
|
||||
value,
|
||||
[
|
||||
'activationDigest',
|
||||
'applicationId',
|
||||
'applicationPlanDigest',
|
||||
'bundleDigest',
|
||||
'bundleFingerprintDigest',
|
||||
'candidateSetDigest',
|
||||
'command',
|
||||
'cutoverId',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'preparationDigest',
|
||||
'profile',
|
||||
'projectId',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'secretConfigPlanDigest',
|
||||
],
|
||||
'intent',
|
||||
);
|
||||
const { preparationDigest, ...payload } = intent;
|
||||
const normalizedCommand =
|
||||
normalizeLocalReconciliationSecretConfigDecisionPrepareCommand(
|
||||
intent.command,
|
||||
);
|
||||
const normalizedPayload = Object.freeze({
|
||||
...payload,
|
||||
command: normalizedCommand,
|
||||
});
|
||||
if (
|
||||
intent.schema !== INTENT_SCHEMA ||
|
||||
intent.schemaVersion !== 1 ||
|
||||
typeof preparationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(preparationDigest) ||
|
||||
cutoverDigest(payload) !== preparationDigest ||
|
||||
cutoverDigest(normalizedPayload) !== preparationDigest ||
|
||||
(intent.profile !== 'edge' && intent.profile !== 'standalone') ||
|
||||
![
|
||||
intent.applicationPlanDigest,
|
||||
intent.secretConfigPlanDigest,
|
||||
intent.candidateSetDigest,
|
||||
intent.bundleDigest,
|
||||
intent.bundleFingerprintDigest,
|
||||
intent.activationDigest,
|
||||
].every(
|
||||
(selected) =>
|
||||
typeof selected === 'string' && DIGEST_PATTERN.test(selected),
|
||||
) ||
|
||||
![
|
||||
intent.applicationId,
|
||||
intent.projectId,
|
||||
intent.instanceId,
|
||||
intent.cutoverId,
|
||||
].every(
|
||||
(selected) => typeof selected === 'string' && selected.length > 0,
|
||||
) ||
|
||||
!Number.isSafeInteger(intent.generation) ||
|
||||
(intent.generation as number) < 1
|
||||
) {
|
||||
configurationError('intent binding is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...normalizedPayload,
|
||||
preparationDigest,
|
||||
}) as unknown as Readonly<LocalReconciliationSecretConfigDecisionIntent>;
|
||||
}
|
||||
|
||||
export function buildLocalReconciliationSecretConfigDecisionReceipt(
|
||||
input: Omit<
|
||||
LocalReconciliationSecretConfigDecisionReceipt,
|
||||
'schema' | 'schemaVersion' | 'state' | 'decisionDigest'
|
||||
>,
|
||||
): Readonly<LocalReconciliationSecretConfigDecisionReceipt> {
|
||||
const payload = Object.freeze({
|
||||
schema: RECEIPT_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
state: 'reconciliation_secret_config_reviewed' as const,
|
||||
...input,
|
||||
});
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
decisionDigest: cutoverDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationSecretConfigDecisionReceipt(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationSecretConfigDecisionReceipt> {
|
||||
const receipt = exact(
|
||||
value,
|
||||
[
|
||||
'applicationPlanDigest',
|
||||
'applyBindingCount',
|
||||
'authorizationDigest',
|
||||
'candidateCount',
|
||||
'candidateSetDigest',
|
||||
'decisionDigest',
|
||||
'decisionFileDigest',
|
||||
'decisionId',
|
||||
'expiresAtMs',
|
||||
'issuedAtMs',
|
||||
'outcome',
|
||||
'preparedHeadDigest',
|
||||
'preserveDisabledCount',
|
||||
'reviewerDigest',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'secretConfigId',
|
||||
'secretConfigPlanDigest',
|
||||
'signedDecisionSetDigest',
|
||||
'skippedCount',
|
||||
'state',
|
||||
],
|
||||
'receipt',
|
||||
);
|
||||
const { decisionDigest, ...payload } = receipt;
|
||||
if (
|
||||
receipt.schema !== RECEIPT_SCHEMA ||
|
||||
receipt.schemaVersion !== 1 ||
|
||||
receipt.state !== 'reconciliation_secret_config_reviewed' ||
|
||||
typeof decisionDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(decisionDigest) ||
|
||||
cutoverDigest(payload) !== decisionDigest ||
|
||||
![
|
||||
receipt.secretConfigPlanDigest,
|
||||
receipt.candidateSetDigest,
|
||||
receipt.applicationPlanDigest,
|
||||
receipt.preparedHeadDigest,
|
||||
receipt.authorizationDigest,
|
||||
receipt.signedDecisionSetDigest,
|
||||
receipt.decisionFileDigest,
|
||||
receipt.reviewerDigest,
|
||||
].every(
|
||||
(selected) =>
|
||||
typeof selected === 'string' && DIGEST_PATTERN.test(selected),
|
||||
) ||
|
||||
![
|
||||
receipt.candidateCount,
|
||||
receipt.applyBindingCount,
|
||||
receipt.preserveDisabledCount,
|
||||
receipt.skippedCount,
|
||||
].every(
|
||||
(selected) => Number.isSafeInteger(selected) && (selected as number) >= 0,
|
||||
) ||
|
||||
(receipt.applyBindingCount as number) +
|
||||
(receipt.preserveDisabledCount as number) +
|
||||
(receipt.skippedCount as number) !==
|
||||
receipt.candidateCount ||
|
||||
(receipt.outcome !== 'ready' && receipt.outcome !== 'manual_required') ||
|
||||
(receipt.outcome === 'ready' && (receipt.skippedCount as number) !== 0) ||
|
||||
(receipt.outcome === 'manual_required' &&
|
||||
(receipt.skippedCount as number) === 0) ||
|
||||
!Number.isSafeInteger(receipt.issuedAtMs) ||
|
||||
(receipt.issuedAtMs as number) < 0 ||
|
||||
!Number.isSafeInteger(receipt.expiresAtMs) ||
|
||||
(receipt.expiresAtMs as number) <= (receipt.issuedAtMs as number) ||
|
||||
typeof receipt.decisionId !== 'string' ||
|
||||
typeof receipt.secretConfigId !== 'string'
|
||||
) {
|
||||
configurationError('receipt binding is invalid');
|
||||
}
|
||||
return Object.freeze(
|
||||
receipt,
|
||||
) as unknown as Readonly<LocalReconciliationSecretConfigDecisionReceipt>;
|
||||
}
|
||||
|
||||
export function localReconciliationSecretConfigDecisionEvidenceContents(
|
||||
value:
|
||||
| Readonly<LocalReconciliationSecretConfigDecisionIntent>
|
||||
| Readonly<LocalReconciliationSecretConfigDecisionReceipt>,
|
||||
): string {
|
||||
return `${JSON.stringify(value, null, 2)}\n`;
|
||||
}
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||
import type { LocalReconciliationSecretConfigDecisionRequirement } from './planReader';
|
||||
|
||||
const HEADER_KIND =
|
||||
'qinglong3-local-reconciliation-secret-config-decision-header';
|
||||
const DECISION_KIND = 'qinglong3-local-reconciliation-secret-config-decision';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const UUID_V7_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const MAX_LINE_BYTES = 64 * 1024;
|
||||
const READ_CHUNK_BYTES = 64 * 1024;
|
||||
export const MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_BYTES =
|
||||
1 * 1024 * 1024;
|
||||
export const MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_BYTES =
|
||||
4 * 1024 * 1024;
|
||||
|
||||
export const LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_DISPOSITIONS =
|
||||
Object.freeze(['apply_active_binding', 'preserve_disabled', 'skip'] as const);
|
||||
export type LocalReconciliationSecretConfigDecisionDisposition =
|
||||
(typeof LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_DISPOSITIONS)[number];
|
||||
|
||||
export const LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_REASONS =
|
||||
Object.freeze([
|
||||
'reviewed_active_binding',
|
||||
'reviewed_disabled_preservation',
|
||||
'operator_excluded',
|
||||
'target_conflict',
|
||||
'security_review_required',
|
||||
] as const);
|
||||
export type LocalReconciliationSecretConfigDecisionReason =
|
||||
(typeof LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_REASONS)[number];
|
||||
|
||||
export interface LocalReconciliationSecretConfigDecisionHeader {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: typeof HEADER_KIND;
|
||||
readonly decisionContractVersion: 1;
|
||||
readonly decisionId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly secretConfigPlanDigest: string;
|
||||
readonly preparationDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigDecision {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: typeof DECISION_KIND;
|
||||
readonly candidateOrdinal: number;
|
||||
readonly candidateDigest: string;
|
||||
readonly disposition: LocalReconciliationSecretConfigDecisionDisposition;
|
||||
readonly reason: LocalReconciliationSecretConfigDecisionReason;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigDecisionFileEvidence {
|
||||
readonly fileBytes: number;
|
||||
readonly fileDigest: string;
|
||||
readonly decisionCount: number;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigDecisionCursor {
|
||||
readonly header: Readonly<LocalReconciliationSecretConfigDecisionHeader>;
|
||||
next(): Readonly<LocalReconciliationSecretConfigDecision> | null;
|
||||
}
|
||||
|
||||
interface FileIdentity {
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
readonly size: bigint;
|
||||
readonly modifiedAtNs: bigint;
|
||||
readonly changedAtNs: bigint;
|
||||
readonly uid: number;
|
||||
readonly mode: number;
|
||||
readonly links: bigint;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation secret config decision file ${message}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const actual = Object.keys(record).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function parse(line: Buffer, label: string): unknown {
|
||||
if (line.byteLength < 2 || line.byteLength > MAX_LINE_BYTES) {
|
||||
configurationError(`${label} line bound is invalid`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(line),
|
||||
) as unknown;
|
||||
} catch (error) {
|
||||
return configurationError(`${label} is not UTF-8 JSON`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function header(
|
||||
value: unknown,
|
||||
expected: Readonly<{
|
||||
decisionId: string;
|
||||
profile: 'edge' | 'standalone';
|
||||
secretConfigPlanDigest: string;
|
||||
preparationDigest: string;
|
||||
}>,
|
||||
): Readonly<LocalReconciliationSecretConfigDecisionHeader> {
|
||||
const record = exact(
|
||||
value,
|
||||
[
|
||||
'decisionContractVersion',
|
||||
'decisionId',
|
||||
'kind',
|
||||
'preparationDigest',
|
||||
'profile',
|
||||
'schemaVersion',
|
||||
'secretConfigPlanDigest',
|
||||
],
|
||||
'header',
|
||||
);
|
||||
if (
|
||||
record.schemaVersion !== 1 ||
|
||||
record.kind !== HEADER_KIND ||
|
||||
record.decisionContractVersion !== 1 ||
|
||||
typeof record.decisionId !== 'string' ||
|
||||
!UUID_V7_PATTERN.test(record.decisionId) ||
|
||||
(record.profile !== 'edge' && record.profile !== 'standalone') ||
|
||||
typeof record.secretConfigPlanDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.secretConfigPlanDigest) ||
|
||||
typeof record.preparationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.preparationDigest) ||
|
||||
record.decisionId !== expected.decisionId ||
|
||||
record.profile !== expected.profile ||
|
||||
record.secretConfigPlanDigest !== expected.secretConfigPlanDigest ||
|
||||
record.preparationDigest !== expected.preparationDigest
|
||||
) {
|
||||
configurationError('header binding is invalid');
|
||||
}
|
||||
return Object.freeze(
|
||||
record,
|
||||
) as unknown as Readonly<LocalReconciliationSecretConfigDecisionHeader>;
|
||||
}
|
||||
|
||||
function decision(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationSecretConfigDecision> {
|
||||
const record = exact(
|
||||
value,
|
||||
[
|
||||
'candidateDigest',
|
||||
'candidateOrdinal',
|
||||
'disposition',
|
||||
'kind',
|
||||
'reason',
|
||||
'schemaVersion',
|
||||
],
|
||||
'decision',
|
||||
);
|
||||
if (
|
||||
record.schemaVersion !== 1 ||
|
||||
record.kind !== DECISION_KIND ||
|
||||
!Number.isSafeInteger(record.candidateOrdinal) ||
|
||||
(record.candidateOrdinal as number) < 1 ||
|
||||
typeof record.candidateDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.candidateDigest) ||
|
||||
!LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_DISPOSITIONS.includes(
|
||||
record.disposition as LocalReconciliationSecretConfigDecisionDisposition,
|
||||
) ||
|
||||
!LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_REASONS.includes(
|
||||
record.reason as LocalReconciliationSecretConfigDecisionReason,
|
||||
)
|
||||
) {
|
||||
configurationError('decision is invalid');
|
||||
}
|
||||
const allowed: Record<
|
||||
LocalReconciliationSecretConfigDecisionDisposition,
|
||||
readonly LocalReconciliationSecretConfigDecisionReason[]
|
||||
> = {
|
||||
apply_active_binding: ['reviewed_active_binding'],
|
||||
preserve_disabled: ['reviewed_disabled_preservation'],
|
||||
skip: ['operator_excluded', 'target_conflict', 'security_review_required'],
|
||||
};
|
||||
if (
|
||||
!allowed[
|
||||
record.disposition as LocalReconciliationSecretConfigDecisionDisposition
|
||||
].includes(record.reason as LocalReconciliationSecretConfigDecisionReason)
|
||||
) {
|
||||
configurationError('decision reason does not match disposition');
|
||||
}
|
||||
return Object.freeze(
|
||||
record,
|
||||
) as unknown as Readonly<LocalReconciliationSecretConfigDecision>;
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationSecretConfigDecision(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationSecretConfigDecision> {
|
||||
return decision(value);
|
||||
}
|
||||
|
||||
export function assertLocalReconciliationSecretConfigDecisionMatchesRequirement(
|
||||
selected: Readonly<LocalReconciliationSecretConfigDecision>,
|
||||
requirement: Readonly<LocalReconciliationSecretConfigDecisionRequirement>,
|
||||
): void {
|
||||
if (
|
||||
selected.candidateOrdinal !== requirement.candidateOrdinal ||
|
||||
selected.candidateDigest !== requirement.candidateDigest
|
||||
) {
|
||||
configurationError('decision sequence does not match canonical candidates');
|
||||
}
|
||||
if (
|
||||
(requirement.requirement === 'review_apply_binding' &&
|
||||
selected.disposition !== 'apply_active_binding' &&
|
||||
selected.disposition !== 'skip') ||
|
||||
(requirement.requirement === 'review_preserve_disabled' &&
|
||||
selected.disposition !== 'preserve_disabled' &&
|
||||
selected.disposition !== 'skip') ||
|
||||
(requirement.requirement === 'review_skip_conflict' &&
|
||||
(selected.disposition !== 'skip' ||
|
||||
!['target_conflict', 'security_review_required'].includes(
|
||||
selected.reason,
|
||||
)))
|
||||
) {
|
||||
configurationError('decision is not allowed for canonical candidate');
|
||||
}
|
||||
}
|
||||
|
||||
function identity(stat: fs.BigIntStats): FileIdentity {
|
||||
return Object.freeze({
|
||||
device: stat.dev,
|
||||
inode: stat.ino,
|
||||
size: stat.size,
|
||||
modifiedAtNs: stat.mtimeNs,
|
||||
changedAtNs: stat.ctimeNs,
|
||||
uid: Number(stat.uid),
|
||||
mode: Number(stat.mode) & 0o777,
|
||||
links: stat.nlink,
|
||||
});
|
||||
}
|
||||
|
||||
function same(left: FileIdentity, right: FileIdentity): boolean {
|
||||
return Object.keys(left).every(
|
||||
(key) =>
|
||||
left[key as keyof FileIdentity] === right[key as keyof FileIdentity],
|
||||
);
|
||||
}
|
||||
|
||||
export function withLocalReconciliationSecretConfigDecisionFile<T>(
|
||||
filePath: string,
|
||||
expected: Readonly<{
|
||||
decisionId: string;
|
||||
profile: 'edge' | 'standalone';
|
||||
secretConfigPlanDigest: string;
|
||||
preparationDigest: string;
|
||||
}>,
|
||||
consume: (cursor: LocalReconciliationSecretConfigDecisionCursor) => T,
|
||||
): Readonly<{
|
||||
result: T;
|
||||
evidence: Readonly<LocalReconciliationSecretConfigDecisionFileEvidence>;
|
||||
confirmIdentity(): void;
|
||||
}> {
|
||||
const uid = process.getuid?.();
|
||||
if (!Number.isSafeInteger(uid) || uid !== process.geteuid?.()) {
|
||||
configurationError('requires stable POSIX identity');
|
||||
}
|
||||
const parentPath = path.dirname(filePath);
|
||||
const parentBefore = identity(fs.lstatSync(parentPath, { bigint: true }));
|
||||
if (
|
||||
parentBefore.uid !== uid ||
|
||||
parentBefore.mode !== 0o700 ||
|
||||
fs.realpathSync(parentPath) !== parentPath
|
||||
) {
|
||||
configurationError('parent must be a canonical current-UID 0700 directory');
|
||||
}
|
||||
const beforeStat = fs.lstatSync(filePath, { bigint: true });
|
||||
const before = identity(beforeStat);
|
||||
const maxBytes =
|
||||
expected.profile === 'edge'
|
||||
? MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_BYTES
|
||||
: MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_BYTES;
|
||||
if (
|
||||
!beforeStat.isFile() ||
|
||||
beforeStat.isSymbolicLink() ||
|
||||
before.uid !== uid ||
|
||||
before.mode !== 0o600 ||
|
||||
before.links !== 1n ||
|
||||
before.size < 2n ||
|
||||
before.size > BigInt(maxBytes)
|
||||
) {
|
||||
configurationError('identity or size is invalid');
|
||||
}
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = identity(fs.fstatSync(descriptor, { bigint: true }));
|
||||
if (!same(before, opened)) configurationError('changed while opening');
|
||||
const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES);
|
||||
const lineBuffer = Buffer.allocUnsafe(MAX_LINE_BYTES);
|
||||
const fileHash = createHash('sha256');
|
||||
let chunkOffset = 0;
|
||||
let chunkLength = 0;
|
||||
let lineLength = 0;
|
||||
let fileBytes = 0;
|
||||
let decisionCount = 0;
|
||||
let ended = false;
|
||||
const nextLine = (): Buffer | null => {
|
||||
while (true) {
|
||||
if (chunkOffset >= chunkLength) {
|
||||
chunkLength = fs.readSync(
|
||||
descriptor!,
|
||||
chunk,
|
||||
0,
|
||||
chunk.byteLength,
|
||||
null,
|
||||
);
|
||||
chunkOffset = 0;
|
||||
if (chunkLength === 0) {
|
||||
if (lineLength !== 0) configurationError('must end with newline');
|
||||
return null;
|
||||
}
|
||||
fileBytes += chunkLength;
|
||||
fileHash.update(chunk.subarray(0, chunkLength));
|
||||
}
|
||||
const byte = chunk[chunkOffset++]!;
|
||||
if (byte === 0x0a) {
|
||||
const line = Buffer.from(lineBuffer.subarray(0, lineLength));
|
||||
lineLength = 0;
|
||||
return line;
|
||||
}
|
||||
if (lineLength >= MAX_LINE_BYTES) {
|
||||
configurationError('line is too large');
|
||||
}
|
||||
lineBuffer[lineLength] = byte;
|
||||
lineLength += 1;
|
||||
}
|
||||
};
|
||||
const first = nextLine();
|
||||
if (!first) configurationError('header is missing');
|
||||
let parsedHeader: Readonly<LocalReconciliationSecretConfigDecisionHeader>;
|
||||
try {
|
||||
parsedHeader = header(parse(first, 'header'), expected);
|
||||
} finally {
|
||||
first.fill(0);
|
||||
}
|
||||
const cursor: LocalReconciliationSecretConfigDecisionCursor = Object.freeze(
|
||||
{
|
||||
header: parsedHeader,
|
||||
next(): Readonly<LocalReconciliationSecretConfigDecision> | null {
|
||||
if (ended) return null;
|
||||
const line = nextLine();
|
||||
if (!line) {
|
||||
ended = true;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const selected = decision(parse(line, 'decision'));
|
||||
decisionCount += 1;
|
||||
return selected;
|
||||
} finally {
|
||||
line.fill(0);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = consume(cursor);
|
||||
if (cursor.next() !== null) {
|
||||
configurationError('decision consumer did not consume the full file');
|
||||
}
|
||||
const after = identity(fs.fstatSync(descriptor, { bigint: true }));
|
||||
const parentAfter = identity(fs.lstatSync(parentPath, { bigint: true }));
|
||||
const current = identity(fs.lstatSync(filePath, { bigint: true }));
|
||||
if (
|
||||
!same(before, after) ||
|
||||
!same(before, current) ||
|
||||
!same(parentBefore, parentAfter) ||
|
||||
fileBytes !== Number(before.size)
|
||||
) {
|
||||
configurationError('identity changed while reading');
|
||||
}
|
||||
const evidence = Object.freeze({
|
||||
fileBytes,
|
||||
fileDigest: fileHash.digest('hex'),
|
||||
decisionCount,
|
||||
});
|
||||
return Object.freeze({
|
||||
result,
|
||||
evidence,
|
||||
confirmIdentity(): void {
|
||||
const parentCurrent = identity(
|
||||
fs.lstatSync(parentPath, { bigint: true }),
|
||||
);
|
||||
const fileCurrent = identity(fs.lstatSync(filePath, { bigint: true }));
|
||||
if (!same(parentBefore, parentCurrent) || !same(before, fileCurrent)) {
|
||||
configurationError('identity changed after reading');
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return configurationError('cannot be read', error);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
+478
@@ -0,0 +1,478 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||
import { cutoverDigest } from '../../../cutover/targetEvidence';
|
||||
import type {
|
||||
LocalReconciliationSecretConfigCandidateRequirement,
|
||||
LocalReconciliationSecretConfigPlanHeader,
|
||||
LocalReconciliationSecretConfigPlanReceipt,
|
||||
} from './rowPlan';
|
||||
|
||||
const HEADER_KIND = 'qinglong3-local-reconciliation-secret-config-plan-header';
|
||||
const ROW_KIND = 'qinglong3-local-reconciliation-secret-config-plan-row';
|
||||
const CANDIDATE_KIND =
|
||||
'qinglong3-local-reconciliation-secret-config-plan-candidate';
|
||||
const FOOTER_KIND = 'qinglong3-local-reconciliation-secret-config-plan-footer';
|
||||
const READ_CHUNK_BYTES = 64 * 1024;
|
||||
const MAX_LINE_BYTES = 64 * 1024;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
interface FileLine {
|
||||
readonly value: Buffer;
|
||||
readonly framed: Buffer;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigDecisionRequirement {
|
||||
readonly candidateOrdinal: number;
|
||||
readonly candidateType: 'active_binding' | 'disabled_preservation';
|
||||
readonly candidateDigest: string;
|
||||
readonly sourceSetDigest: string;
|
||||
readonly requirement: LocalReconciliationSecretConfigCandidateRequirement;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation secret config plan reader ${message}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const actual = Object.keys(record).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function parse(line: Buffer, label: string): unknown {
|
||||
if (line.length < 2 || line.length > MAX_LINE_BYTES) {
|
||||
configurationError(`${label} exceeds its line bound`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(line),
|
||||
) as unknown;
|
||||
} catch (error) {
|
||||
return configurationError(`${label} is not UTF-8 JSON`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function* lines(descriptor: number, size: number): Iterable<FileLine> {
|
||||
let position = 0;
|
||||
let pending = Buffer.alloc(0);
|
||||
try {
|
||||
while (position < size) {
|
||||
const chunk = Buffer.allocUnsafe(
|
||||
Math.min(READ_CHUNK_BYTES, size - position),
|
||||
);
|
||||
const bytesRead = fs.readSync(
|
||||
descriptor,
|
||||
chunk,
|
||||
0,
|
||||
chunk.length,
|
||||
position,
|
||||
);
|
||||
if (bytesRead < 1) {
|
||||
chunk.fill(0);
|
||||
configurationError('file ended unexpectedly');
|
||||
}
|
||||
position += bytesRead;
|
||||
const material = pending.length
|
||||
? Buffer.concat([pending, chunk.subarray(0, bytesRead)])
|
||||
: Buffer.from(chunk.subarray(0, bytesRead));
|
||||
pending.fill(0);
|
||||
chunk.fill(0);
|
||||
let cursor = 0;
|
||||
for (;;) {
|
||||
const newline = material.indexOf(0x0a, cursor);
|
||||
if (newline < 0) break;
|
||||
const framed = Buffer.from(material.subarray(cursor, newline + 1));
|
||||
const value = Buffer.from(material.subarray(cursor, newline));
|
||||
cursor = newline + 1;
|
||||
yield Object.freeze({ value, framed });
|
||||
}
|
||||
pending = Buffer.from(material.subarray(cursor));
|
||||
material.fill(0);
|
||||
if (pending.length > MAX_LINE_BYTES) {
|
||||
configurationError('record exceeds its line bound');
|
||||
}
|
||||
}
|
||||
if (pending.length !== 0) configurationError('file is not newline framed');
|
||||
} finally {
|
||||
pending.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function candidate(
|
||||
value: unknown,
|
||||
expectedOrdinal: number,
|
||||
): Readonly<LocalReconciliationSecretConfigDecisionRequirement> {
|
||||
const record = exact(
|
||||
value,
|
||||
[
|
||||
'candidateDigest',
|
||||
'candidateOrdinal',
|
||||
'candidatePlanDigest',
|
||||
'candidateType',
|
||||
'kind',
|
||||
'proposedSecretName',
|
||||
'requirement',
|
||||
'schemaVersion',
|
||||
'sourceRowCount',
|
||||
'sourceSetDigest',
|
||||
'target',
|
||||
],
|
||||
'candidate',
|
||||
);
|
||||
const { candidatePlanDigest, ...payload } = record;
|
||||
const target = record.target as Record<string, unknown> | undefined;
|
||||
if (
|
||||
record.schemaVersion !== 1 ||
|
||||
record.kind !== CANDIDATE_KIND ||
|
||||
record.candidateOrdinal !== expectedOrdinal ||
|
||||
(record.candidateType !== 'active_binding' &&
|
||||
record.candidateType !== 'disabled_preservation') ||
|
||||
typeof record.candidateDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.candidateDigest) ||
|
||||
!Number.isSafeInteger(record.sourceRowCount) ||
|
||||
(record.sourceRowCount as number) < 1 ||
|
||||
typeof record.sourceSetDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.sourceSetDigest) ||
|
||||
typeof record.proposedSecretName !== 'string' ||
|
||||
record.proposedSecretName.length < 1 ||
|
||||
![
|
||||
'review_apply_binding',
|
||||
'review_preserve_disabled',
|
||||
'review_skip_conflict',
|
||||
].includes(record.requirement as string) ||
|
||||
!target ||
|
||||
Array.isArray(target) ||
|
||||
typeof candidatePlanDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(candidatePlanDigest) ||
|
||||
cutoverDigest(payload) !== candidatePlanDigest
|
||||
) {
|
||||
configurationError('candidate binding is invalid');
|
||||
}
|
||||
const targetKeys = Object.keys(target).sort().join('\0');
|
||||
if (
|
||||
(target.state === 'absent' && targetKeys !== 'state') ||
|
||||
(target.state === 'occupied' &&
|
||||
(targetKeys !== ['contentDigest', 'state', 'version'].sort().join('\0') ||
|
||||
!Number.isSafeInteger(target.version) ||
|
||||
(target.version as number) < 1 ||
|
||||
typeof target.contentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(target.contentDigest))) ||
|
||||
(target.state !== 'absent' && target.state !== 'occupied') ||
|
||||
(record.requirement === 'review_skip_conflict' &&
|
||||
target.state !== 'occupied') ||
|
||||
(record.requirement !== 'review_skip_conflict' &&
|
||||
target.state !== 'absent') ||
|
||||
(record.requirement === 'review_apply_binding' &&
|
||||
record.candidateType !== 'active_binding') ||
|
||||
(record.requirement === 'review_preserve_disabled' &&
|
||||
record.candidateType !== 'disabled_preservation')
|
||||
) {
|
||||
configurationError('candidate target requirement is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
candidateOrdinal: record.candidateOrdinal as number,
|
||||
candidateType: record.candidateType as
|
||||
| 'active_binding'
|
||||
| 'disabled_preservation',
|
||||
candidateDigest: record.candidateDigest,
|
||||
sourceSetDigest: record.sourceSetDigest,
|
||||
requirement:
|
||||
record.requirement as LocalReconciliationSecretConfigCandidateRequirement,
|
||||
});
|
||||
}
|
||||
|
||||
function header(
|
||||
value: unknown,
|
||||
receipt: Readonly<LocalReconciliationSecretConfigPlanReceipt>,
|
||||
): Readonly<LocalReconciliationSecretConfigPlanHeader> {
|
||||
const record = exact(
|
||||
value,
|
||||
[
|
||||
'applicationId',
|
||||
'applicationPlanDigest',
|
||||
'bundleDigest',
|
||||
'bundleFingerprintDigest',
|
||||
'headerDigest',
|
||||
'kind',
|
||||
'preparedAtMs',
|
||||
'preparedHeadDigest',
|
||||
'profile',
|
||||
'projectId',
|
||||
'reviewAuthorizationDigest',
|
||||
'reviewDecisionFileDigest',
|
||||
'reviewDecisionSetDigest',
|
||||
'reviewDigest',
|
||||
'schemaVersion',
|
||||
'secretConfigId',
|
||||
'tableDisposition',
|
||||
'unadaptedLegacyConfigCount',
|
||||
],
|
||||
'header',
|
||||
);
|
||||
const { headerDigest, ...payload } = record;
|
||||
if (
|
||||
record.schemaVersion !== 1 ||
|
||||
record.kind !== HEADER_KIND ||
|
||||
record.secretConfigId !== receipt.secretConfigId ||
|
||||
record.applicationId !== receipt.applicationId ||
|
||||
record.applicationPlanDigest !== receipt.applicationPlanDigest ||
|
||||
record.preparedHeadDigest !== receipt.preparedHeadDigest ||
|
||||
record.preparedAtMs !== receipt.preparedAtMs ||
|
||||
(record.profile !== 'edge' && record.profile !== 'standalone') ||
|
||||
typeof record.projectId !== 'string' ||
|
||||
record.projectId.length < 1 ||
|
||||
(record.tableDisposition !== 'absent' &&
|
||||
record.tableDisposition !== 'manual_external') ||
|
||||
!Number.isSafeInteger(record.unadaptedLegacyConfigCount) ||
|
||||
(record.unadaptedLegacyConfigCount as number) < 0 ||
|
||||
![
|
||||
record.applicationPlanDigest,
|
||||
record.bundleDigest,
|
||||
record.bundleFingerprintDigest,
|
||||
record.reviewAuthorizationDigest,
|
||||
record.reviewDecisionFileDigest,
|
||||
record.reviewDecisionSetDigest,
|
||||
record.reviewDigest,
|
||||
record.preparedHeadDigest,
|
||||
headerDigest,
|
||||
].every(
|
||||
(selected) =>
|
||||
typeof selected === 'string' && DIGEST_PATTERN.test(selected),
|
||||
) ||
|
||||
cutoverDigest(payload) !== headerDigest
|
||||
) {
|
||||
configurationError('header binding is invalid');
|
||||
}
|
||||
return record as unknown as Readonly<LocalReconciliationSecretConfigPlanHeader>;
|
||||
}
|
||||
|
||||
export function readLocalReconciliationSecretConfigPlanHeader(
|
||||
filePath: string,
|
||||
receipt: Readonly<LocalReconciliationSecretConfigPlanReceipt>,
|
||||
uid: number,
|
||||
): Readonly<LocalReconciliationSecretConfigPlanHeader> {
|
||||
let descriptor: number | undefined;
|
||||
const bytes = Buffer.allocUnsafe(MAX_LINE_BYTES + 1);
|
||||
try {
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== uid ||
|
||||
(Number(before.mode) & 0o777) !== 0o400 ||
|
||||
before.nlink !== 1n ||
|
||||
before.size !== BigInt(receipt.planFileBytes)
|
||||
) {
|
||||
configurationError('plan file identity is invalid');
|
||||
}
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.size !== before.size ||
|
||||
opened.mtimeNs !== before.mtimeNs ||
|
||||
opened.ctimeNs !== before.ctimeNs
|
||||
) {
|
||||
configurationError('plan file changed while opening');
|
||||
}
|
||||
const bytesRead = fs.readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
0,
|
||||
Math.min(bytes.length, Number(opened.size)),
|
||||
0,
|
||||
);
|
||||
const newline = bytes.subarray(0, bytesRead).indexOf(0x0a);
|
||||
if (newline < 2 || newline > MAX_LINE_BYTES) {
|
||||
configurationError('header is missing or exceeds its line bound');
|
||||
}
|
||||
const selected = header(
|
||||
parse(bytes.subarray(0, newline), 'header'),
|
||||
receipt,
|
||||
);
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
const linked = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
after.dev !== opened.dev ||
|
||||
after.ino !== opened.ino ||
|
||||
after.size !== opened.size ||
|
||||
after.mtimeNs !== opened.mtimeNs ||
|
||||
after.ctimeNs !== opened.ctimeNs ||
|
||||
linked.dev !== opened.dev ||
|
||||
linked.ino !== opened.ino ||
|
||||
linked.size !== opened.size ||
|
||||
linked.mtimeNs !== opened.mtimeNs ||
|
||||
linked.ctimeNs !== opened.ctimeNs
|
||||
) {
|
||||
configurationError('plan file changed while reading header');
|
||||
}
|
||||
return selected;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return configurationError('plan header cannot be read', error);
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalReconciliationSecretConfigDecisionRequirementFactory(
|
||||
filePath: string,
|
||||
receipt: Readonly<LocalReconciliationSecretConfigPlanReceipt>,
|
||||
uid: number,
|
||||
): () => Iterable<LocalReconciliationSecretConfigDecisionRequirement> {
|
||||
return () =>
|
||||
(function* (): Iterable<LocalReconciliationSecretConfigDecisionRequirement> {
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== uid ||
|
||||
(Number(before.mode) & 0o777) !== 0o400 ||
|
||||
before.nlink !== 1n ||
|
||||
before.size !== BigInt(receipt.planFileBytes)
|
||||
) {
|
||||
configurationError('plan file identity is invalid');
|
||||
}
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.size !== before.size ||
|
||||
opened.mtimeNs !== before.mtimeNs ||
|
||||
opened.ctimeNs !== before.ctimeNs
|
||||
) {
|
||||
configurationError('plan file changed while opening');
|
||||
}
|
||||
const fileHash = createHash('sha256');
|
||||
const candidateHash = createHash('sha256').update(
|
||||
'qinglong3.local-reconciliation-secret-config-candidate-set.v1\0',
|
||||
);
|
||||
let headerSeen = false;
|
||||
let footerSeen = false;
|
||||
let candidateCount = 0;
|
||||
for (const selected of lines(descriptor, Number(opened.size))) {
|
||||
try {
|
||||
fileHash.update(selected.framed);
|
||||
const value = parse(selected.value, 'record');
|
||||
const kind = (value as { readonly kind?: unknown })?.kind;
|
||||
if (!headerSeen) {
|
||||
header(value, receipt);
|
||||
headerSeen = true;
|
||||
continue;
|
||||
}
|
||||
if (footerSeen) configurationError('record follows footer');
|
||||
if (kind === ROW_KIND) continue;
|
||||
if (kind === CANDIDATE_KIND) {
|
||||
const requirement = candidate(value, candidateCount + 1);
|
||||
candidateHash.update(selected.framed);
|
||||
candidateCount += 1;
|
||||
yield requirement;
|
||||
continue;
|
||||
}
|
||||
const footer = exact(
|
||||
value,
|
||||
[
|
||||
'activeGroupCount',
|
||||
'activeRowCount',
|
||||
'adoptedLegacyTaskCount',
|
||||
'automationAdoptionRecordCount',
|
||||
'automationAdoptionSetDigest',
|
||||
'bindingReadyCount',
|
||||
'candidateSetDigest',
|
||||
'disabledRowCount',
|
||||
'eligibleBindingCount',
|
||||
'eligiblePreservationCount',
|
||||
'kind',
|
||||
'legacyInventoryDigest',
|
||||
'manualGroupCount',
|
||||
'manualRowCount',
|
||||
'outcome',
|
||||
'preservationReadyCount',
|
||||
'rowCount',
|
||||
'rowSetDigest',
|
||||
'schemaVersion',
|
||||
'secretConfigId',
|
||||
'secretConfigPlanDigest',
|
||||
'tableState',
|
||||
'targetConflictCount',
|
||||
'unadaptedLegacyConfigCount',
|
||||
],
|
||||
'footer',
|
||||
);
|
||||
if (
|
||||
footer.schemaVersion !== 1 ||
|
||||
footer.kind !== FOOTER_KIND ||
|
||||
footer.secretConfigId !== receipt.secretConfigId ||
|
||||
footer.secretConfigPlanDigest !==
|
||||
receipt.secretConfigPlanDigest ||
|
||||
footer.candidateSetDigest !== candidateHash.digest('hex') ||
|
||||
footer.candidateSetDigest !== receipt.candidateSetDigest ||
|
||||
footer.eligibleBindingCount !== receipt.eligibleBindingCount ||
|
||||
footer.eligiblePreservationCount !==
|
||||
receipt.eligiblePreservationCount ||
|
||||
footer.targetConflictCount !== receipt.targetConflictCount ||
|
||||
footer.outcome !== receipt.outcome ||
|
||||
candidateCount !==
|
||||
receipt.eligibleBindingCount +
|
||||
receipt.eligiblePreservationCount +
|
||||
receipt.targetConflictCount
|
||||
) {
|
||||
configurationError('footer binding is invalid');
|
||||
}
|
||||
footerSeen = true;
|
||||
} finally {
|
||||
selected.value.fill(0);
|
||||
selected.framed.fill(0);
|
||||
}
|
||||
}
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!headerSeen ||
|
||||
!footerSeen ||
|
||||
fileHash.digest('hex') !== receipt.planFileDigest ||
|
||||
after.dev !== opened.dev ||
|
||||
after.ino !== opened.ino ||
|
||||
after.size !== opened.size ||
|
||||
after.mtimeNs !== opened.mtimeNs ||
|
||||
after.ctimeNs !== opened.ctimeNs
|
||||
) {
|
||||
configurationError('plan file content drifted');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
configurationError('plan file cannot be read', error);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
})();
|
||||
}
|
||||
+3
-1
@@ -775,7 +775,9 @@ function replayResult(
|
||||
head.state === 'reconciliation_automation_apply_prepared' ||
|
||||
head.state === 'reconciliation_automation_applied' ||
|
||||
head.state === 'reconciliation_automation_rolled_back' ||
|
||||
head.state === 'reconciliation_secret_config_planned');
|
||||
head.state === 'reconciliation_secret_config_planned' ||
|
||||
head.state === 'reconciliation_secret_config_decision_prepared' ||
|
||||
head.state === 'reconciliation_secret_config_reviewed');
|
||||
const completionRestartPendingHead =
|
||||
(record.state === 'target_active' || record.state === 'manual_required') &&
|
||||
completionFence !== undefined &&
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
commitLocalReconciliationCapture,
|
||||
commitLocalReconciliationApplication,
|
||||
commitLocalReconciliationAutomationDecision,
|
||||
commitLocalReconciliationSecretConfigDecision,
|
||||
completeLocalReconciliation,
|
||||
applyLocalReconciliationAutomation,
|
||||
commitLocalReconciliationPlan,
|
||||
@@ -18,8 +19,10 @@ const {
|
||||
prepareLocalReconciliationCapture,
|
||||
prepareLocalReconciliationApplication,
|
||||
prepareLocalReconciliationAutomationDecision,
|
||||
prepareLocalReconciliationSecretConfigDecision,
|
||||
preserveLocalReconciliationRunHistory,
|
||||
readLocalReconciliationAutomationDecisionTerminal,
|
||||
readLocalReconciliationSecretConfigDecisionTerminal,
|
||||
rollbackLocalReconciliationAutomationApply,
|
||||
planLocalReconciliationAutomation,
|
||||
planLocalReconciliationSecretConfig,
|
||||
@@ -28,6 +31,7 @@ const {
|
||||
verifyLocalReconciliationCapture,
|
||||
verifyLocalReconciliationApplication,
|
||||
verifyLocalReconciliationAutomationDecision,
|
||||
verifyLocalReconciliationSecretConfigDecision,
|
||||
verifyLocalReconciliationAutomationApply,
|
||||
verifyLocalReconciliationAutomationPlan,
|
||||
verifyLocalReconciliationSecretConfigPlan,
|
||||
@@ -1224,10 +1228,8 @@ function applicationCommitCommand(state, prepared) {
|
||||
async function secretConfigPlanFixture(t, options = {}) {
|
||||
const suffix = options.suffix ?? 'plan';
|
||||
const state = await reviewedApplicationFixture(t, {
|
||||
planId:
|
||||
options.planId ?? '00000000-0000-4000-8000-000000000421',
|
||||
reviewId:
|
||||
options.reviewId ?? '00000000-0000-4000-8000-000000000422',
|
||||
planId: options.planId ?? '00000000-0000-4000-8000-000000000421',
|
||||
reviewId: options.reviewId ?? '00000000-0000-4000-8000-000000000422',
|
||||
applicationId:
|
||||
options.applicationId ?? '00000000-0000-4000-8000-000000000423',
|
||||
reviewSuffix: `secret-config-${suffix}`,
|
||||
@@ -1522,6 +1524,185 @@ function automationDecisionCommitFixture(
|
||||
};
|
||||
}
|
||||
|
||||
async function plannedSecretConfigDecisionFixture(t, options = {}) {
|
||||
const suffix = options.suffix ?? 'decision';
|
||||
const state = await secretConfigPlanFixture(t, {
|
||||
suffix,
|
||||
active: options.active === true,
|
||||
configs: options.configs === true,
|
||||
planId: options.planId,
|
||||
reviewId: options.reviewId,
|
||||
applicationId: options.applicationId,
|
||||
secretConfigId: options.secretConfigId,
|
||||
});
|
||||
const planned = await planLocalReconciliationSecretConfig(
|
||||
state.secretConfigCommand,
|
||||
);
|
||||
const secretConfigDecisionRoot = path.join(
|
||||
path.dirname(state.captureRoot),
|
||||
`secret-config-decision-${suffix}`,
|
||||
);
|
||||
fs.mkdirSync(secretConfigDecisionRoot, { mode: 0o700 });
|
||||
const planPath = path.join(
|
||||
state.secretConfigRoot,
|
||||
state.secretConfigId,
|
||||
'plan.ndjson',
|
||||
);
|
||||
const candidates = fs
|
||||
.readFileSync(planPath, 'utf8')
|
||||
.trimEnd()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line))
|
||||
.filter(
|
||||
(record) =>
|
||||
record.kind ===
|
||||
'qinglong3-local-reconciliation-secret-config-plan-candidate',
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
planned,
|
||||
secretConfigDecisionRoot,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
function secretConfigDecisionPrepareCommand(state, decisionId) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.secret-config.decision.prepare',
|
||||
options: {
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
applicationRoot: state.applicationRoot,
|
||||
secretConfigRoot: state.secretConfigRoot,
|
||||
secretConfigDecisionRoot: state.secretConfigDecisionRoot,
|
||||
allowRootService: rootAcknowledgement(),
|
||||
},
|
||||
request: {
|
||||
decisionId,
|
||||
secretConfigId: state.secretConfigId,
|
||||
expectedSecretConfigPlanDigest: state.planned.secretConfigPlanDigest,
|
||||
expectedHeadDigest: state.planned.instanceHeadDigest,
|
||||
preparedAtMs: state.secretConfigCommand.request.preparedAtMs + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function secretConfigDecisionFile(
|
||||
state,
|
||||
prepared,
|
||||
dispositions,
|
||||
suffix = 'decision',
|
||||
) {
|
||||
assert.equal(state.candidates.length, dispositions.length);
|
||||
const records = [
|
||||
{
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-reconciliation-secret-config-decision-header',
|
||||
decisionContractVersion: 1,
|
||||
decisionId: prepared.result.decisionId,
|
||||
profile: state.captureCommand.request.profile,
|
||||
secretConfigPlanDigest: state.planned.secretConfigPlanDigest,
|
||||
preparationDigest: prepared.result.preparationDigest,
|
||||
},
|
||||
...state.candidates.map((candidate, index) => ({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-reconciliation-secret-config-decision',
|
||||
candidateOrdinal: candidate.candidateOrdinal,
|
||||
candidateDigest: candidate.candidateDigest,
|
||||
disposition: dispositions[index].disposition,
|
||||
reason: dispositions[index].reason,
|
||||
})),
|
||||
];
|
||||
const filePath = path.join(
|
||||
state.diagnosticRoot,
|
||||
`secret-config-decision-${suffix}.ndjson`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
`${records.map((record) => JSON.stringify(record)).join('\n')}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return { filePath, records };
|
||||
}
|
||||
|
||||
function secretConfigDecisionCommitFixture(
|
||||
state,
|
||||
prepared,
|
||||
decisionFilePath,
|
||||
options = {},
|
||||
) {
|
||||
const committedAtMs = options.committedAtMs ?? Date.now();
|
||||
const authorizationLifetimeMs = 10 * 60 * 1_000;
|
||||
let authentications = 0;
|
||||
let confirmations = 0;
|
||||
let databaseCloses = 0;
|
||||
const command = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.secret-config.decision.commit',
|
||||
options: {
|
||||
...prepared.commandOptions,
|
||||
targetDatabasePath: state.targetDatabasePath,
|
||||
ownerPepperKeyringDirectory:
|
||||
state.command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: state.command.options.credentialFilePath,
|
||||
},
|
||||
request: {
|
||||
decisionId: prepared.result.decisionId,
|
||||
secretConfigId: state.secretConfigId,
|
||||
expectedPreparationDigest: prepared.result.preparationDigest,
|
||||
expectedHeadDigest: prepared.result.instanceHeadDigest,
|
||||
decisionFilePath,
|
||||
committedAtMs,
|
||||
authorizationLifetimeMs,
|
||||
},
|
||||
};
|
||||
const dependencies = {
|
||||
now: () => committedAtMs,
|
||||
async openAuthenticationDatabase() {
|
||||
return {
|
||||
async close() {
|
||||
databaseCloses += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
async authenticate(_database, authenticateOptions) {
|
||||
authentications += 1;
|
||||
assert.equal(
|
||||
authenticateOptions.authenticationNamespace,
|
||||
'local_reconciliation_secret_config',
|
||||
);
|
||||
return {
|
||||
principal: {
|
||||
subject: {
|
||||
type: 'user',
|
||||
id: options.reviewerId ?? 'review-owner',
|
||||
},
|
||||
authenticationId: 'local_reconciliation_secret_config:test',
|
||||
authenticatedAtMs: committedAtMs,
|
||||
expiresAtMs: committedAtMs + authorizationLifetimeMs + 60_000,
|
||||
assurance: options.assurance ?? 'local_console',
|
||||
},
|
||||
databaseFence: {
|
||||
credentialId: options.reviewerId ?? 'review-owner',
|
||||
credentialVersion: 1,
|
||||
pepperKeyId: 'review-owner-v1',
|
||||
pepperVersion: 1,
|
||||
},
|
||||
async confirm() {
|
||||
confirmations += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
command,
|
||||
dependencies,
|
||||
authenticationCount: () => authentications,
|
||||
confirmationCount: () => confirmations,
|
||||
databaseCloseCount: () => databaseCloses,
|
||||
};
|
||||
}
|
||||
|
||||
async function appliedAutomationFixture(t, options = {}) {
|
||||
const state = await plannedAutomationFixture(t, {
|
||||
suffix: options.suffix ?? 'completion-applied',
|
||||
@@ -3353,7 +3534,10 @@ test('Secret/Config plan publishes, seals, verifies and stays content-free', asy
|
||||
assert.equal(fs.statSync(root).mode & 0o777, 0o500);
|
||||
assert.equal(fs.statSync(path.join(root, 'staging')).mode & 0o777, 0o500);
|
||||
assert.equal(fs.statSync(path.join(root, 'plan.ndjson')).mode & 0o777, 0o400);
|
||||
assert.equal(fs.statSync(path.join(root, 'receipt.json')).mode & 0o777, 0o400);
|
||||
assert.equal(
|
||||
fs.statSync(path.join(root, 'receipt.json')).mode & 0o777,
|
||||
0o400,
|
||||
);
|
||||
const serialized = fs.readFileSync(path.join(root, 'plan.ndjson'), 'utf8');
|
||||
for (const privateValue of [
|
||||
'DISABLED_TOKEN',
|
||||
@@ -3483,17 +3667,384 @@ test('Secret/Config plan keeps active Env and unknown Configs manual', async (t)
|
||||
assert.equal(planned.outcome, 'manual_required');
|
||||
assert.equal(planned.unadaptedLegacyConfigCount, 1);
|
||||
const serialized = fs.readFileSync(
|
||||
path.join(
|
||||
state.secretConfigRoot,
|
||||
state.secretConfigId,
|
||||
'plan.ndjson',
|
||||
),
|
||||
path.join(state.secretConfigRoot, state.secretConfigId, 'plan.ndjson'),
|
||||
'utf8',
|
||||
);
|
||||
assert.equal(serialized.includes('private-config-value'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('Secret/Config decision reauthenticates the same reviewer, seals exact candidates and verifies content-free', async (t) => {
|
||||
const state = await plannedSecretConfigDecisionFixture(t, {
|
||||
suffix: 'decision-terminal',
|
||||
planId: '00000000-0000-4000-8000-000000000425',
|
||||
reviewId: '00000000-0000-4000-8000-000000000426',
|
||||
applicationId: '00000000-0000-4000-8000-000000000427',
|
||||
secretConfigId: '00000000-0000-4000-8000-000000000428',
|
||||
});
|
||||
assert.equal(state.planned.outcome, 'ready');
|
||||
assert.equal(state.candidates.length, 1);
|
||||
assert.equal(state.candidates[0].requirement, 'review_preserve_disabled');
|
||||
const decisionId = '019b0000-0000-7000-8000-000000000425';
|
||||
const prepareCommand = secretConfigDecisionPrepareCommand(state, decisionId);
|
||||
const prepared = await prepareLocalReconciliationSecretConfigDecision(
|
||||
prepareCommand,
|
||||
);
|
||||
assert.equal(prepared.status, 'prepared');
|
||||
assert.equal(
|
||||
prepared.state,
|
||||
'reconciliation_secret_config_decision_prepared',
|
||||
);
|
||||
const decisionFile = secretConfigDecisionFile(
|
||||
state,
|
||||
{ result: prepared },
|
||||
[
|
||||
{
|
||||
disposition: 'preserve_disabled',
|
||||
reason: 'reviewed_disabled_preservation',
|
||||
},
|
||||
],
|
||||
'terminal',
|
||||
);
|
||||
const commit = secretConfigDecisionCommitFixture(
|
||||
state,
|
||||
{ result: prepared, commandOptions: prepareCommand.options },
|
||||
decisionFile.filePath,
|
||||
);
|
||||
const targetBytes = fs.readFileSync(state.targetDatabasePath);
|
||||
const committed = await commitLocalReconciliationSecretConfigDecision(
|
||||
commit.command,
|
||||
commit.dependencies,
|
||||
);
|
||||
assert.equal(committed.status, 'prepared');
|
||||
assert.equal(committed.state, 'reconciliation_secret_config_reviewed');
|
||||
assert.equal(committed.outcome, 'ready');
|
||||
assert.equal(committed.candidateCount, 1);
|
||||
assert.equal(committed.applyBindingCount, 0);
|
||||
assert.equal(committed.preserveDisabledCount, 1);
|
||||
assert.equal(committed.skippedCount, 0);
|
||||
assert.equal(commit.authenticationCount(), 1);
|
||||
assert.equal(commit.confirmationCount(), 3);
|
||||
assert.equal(commit.databaseCloseCount(), 1);
|
||||
assert.equal(
|
||||
fs.readFileSync(state.targetDatabasePath).equals(targetBytes),
|
||||
true,
|
||||
);
|
||||
const decisionRoot = path.join(
|
||||
state.secretConfigDecisionRoot,
|
||||
state.secretConfigId,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(decisionRoot).sort(), [
|
||||
'authorization.ndjson',
|
||||
'intent.json',
|
||||
'receipt.json',
|
||||
'staging',
|
||||
]);
|
||||
assert.equal(fs.statSync(decisionRoot).mode & 0o777, 0o500);
|
||||
assert.equal(
|
||||
fs.statSync(path.join(decisionRoot, 'staging')).mode & 0o777,
|
||||
0o500,
|
||||
);
|
||||
for (const name of ['authorization.ndjson', 'intent.json', 'receipt.json']) {
|
||||
assert.equal(
|
||||
fs.statSync(path.join(decisionRoot, name)).mode & 0o777,
|
||||
0o400,
|
||||
);
|
||||
}
|
||||
const verifyCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.secret-config.decision.verify',
|
||||
options: prepareCommand.options,
|
||||
request: {
|
||||
decisionId,
|
||||
secretConfigId: state.secretConfigId,
|
||||
expectedDecisionDigest: committed.decisionDigest,
|
||||
},
|
||||
};
|
||||
const verified = await verifyLocalReconciliationSecretConfigDecision(
|
||||
verifyCommand,
|
||||
);
|
||||
assert.equal(verified.status, 'verified');
|
||||
assert.equal(
|
||||
verified.signedDecisionSetDigest,
|
||||
committed.signedDecisionSetDigest,
|
||||
);
|
||||
const terminal = await readLocalReconciliationSecretConfigDecisionTerminal(
|
||||
prepareCommand.options,
|
||||
state.secretConfigId,
|
||||
process.getuid(),
|
||||
);
|
||||
assert.equal(terminal.receipt.decisionDigest, committed.decisionDigest);
|
||||
assert.equal(terminal.reviewer.subject.id, 'review-owner');
|
||||
const serialized = JSON.stringify(verified);
|
||||
for (const privateValue of [
|
||||
'DISABLED_TOKEN',
|
||||
'private-secret-value',
|
||||
'review-owner',
|
||||
decisionFile.filePath,
|
||||
state.candidates[0].candidateDigest,
|
||||
]) {
|
||||
assert.equal(serialized.includes(privateValue), false);
|
||||
}
|
||||
const commandPath = path.join(
|
||||
state.deploymentRoot,
|
||||
'secret-config-decision-verify.json',
|
||||
);
|
||||
fs.writeFileSync(commandPath, `${JSON.stringify(verifyCommand)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
const cli = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(__dirname, '../dist/deployment/localDeploymentCli.js'),
|
||||
'reconciliation-secret-config-decision-verify',
|
||||
'--command-file',
|
||||
commandPath,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(cli.status, 0, cli.stderr);
|
||||
assert.equal(JSON.parse(cli.stdout).status, 'verified');
|
||||
assert.equal(cli.stdout.includes('review-owner'), false);
|
||||
assert.equal(cli.stdout.includes('DISABLED_TOKEN'), false);
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
state.deploymentRoot,
|
||||
state.captureCommand.request.instanceId,
|
||||
state.uid,
|
||||
);
|
||||
assert.equal(head.state, 'reconciliation_secret_config_reviewed');
|
||||
assert.equal(head.sourceRecordDigest, committed.decisionDigest);
|
||||
|
||||
const authorizationPath = path.join(decisionRoot, 'authorization.ndjson');
|
||||
fs.chmodSync(decisionRoot, 0o700);
|
||||
fs.chmodSync(authorizationPath, 0o600);
|
||||
fs.appendFileSync(authorizationPath, '{}\n');
|
||||
fs.chmodSync(authorizationPath, 0o400);
|
||||
fs.chmodSync(decisionRoot, 0o500);
|
||||
await assert.rejects(
|
||||
verifyLocalReconciliationSecretConfigDecision(verifyCommand),
|
||||
/authorization|file identity or size|file is incomplete/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Secret/Config decision rejects manual plans, invalid candidate choices and reviewer drift', async (t) => {
|
||||
const manual = await plannedSecretConfigDecisionFixture(t, {
|
||||
suffix: 'decision-manual-plan',
|
||||
active: true,
|
||||
planId: '00000000-0000-4000-8000-000000000429',
|
||||
reviewId: '00000000-0000-4000-8000-00000000042a',
|
||||
applicationId: '00000000-0000-4000-8000-00000000042b',
|
||||
secretConfigId: '00000000-0000-4000-8000-00000000042c',
|
||||
});
|
||||
assert.equal(manual.planned.outcome, 'manual_required');
|
||||
await assert.rejects(
|
||||
prepareLocalReconciliationSecretConfigDecision(
|
||||
secretConfigDecisionPrepareCommand(
|
||||
manual,
|
||||
'019b0000-0000-7000-8000-000000000429',
|
||||
),
|
||||
),
|
||||
/only a ready non-empty plan can be reviewed/,
|
||||
);
|
||||
|
||||
const state = await plannedSecretConfigDecisionFixture(t, {
|
||||
suffix: 'decision-reject',
|
||||
planId: '00000000-0000-4000-8000-00000000042d',
|
||||
reviewId: '00000000-0000-4000-8000-00000000042e',
|
||||
applicationId: '00000000-0000-4000-8000-00000000042f',
|
||||
secretConfigId: '00000000-0000-4000-8000-000000000430',
|
||||
});
|
||||
const prepareCommand = secretConfigDecisionPrepareCommand(
|
||||
state,
|
||||
'019b0000-0000-7000-8000-00000000042d',
|
||||
);
|
||||
const prepared = await prepareLocalReconciliationSecretConfigDecision(
|
||||
prepareCommand,
|
||||
);
|
||||
const invalid = secretConfigDecisionFile(
|
||||
state,
|
||||
{ result: prepared },
|
||||
[
|
||||
{
|
||||
disposition: 'apply_active_binding',
|
||||
reason: 'reviewed_active_binding',
|
||||
},
|
||||
],
|
||||
'invalid-choice',
|
||||
);
|
||||
const invalidCommit = secretConfigDecisionCommitFixture(
|
||||
state,
|
||||
{ result: prepared, commandOptions: prepareCommand.options },
|
||||
invalid.filePath,
|
||||
);
|
||||
await assert.rejects(
|
||||
commitLocalReconciliationSecretConfigDecision(
|
||||
invalidCommit.command,
|
||||
invalidCommit.dependencies,
|
||||
),
|
||||
/decision is not allowed for canonical candidate/,
|
||||
);
|
||||
assert.equal(invalidCommit.authenticationCount(), 0);
|
||||
|
||||
const valid = secretConfigDecisionFile(
|
||||
state,
|
||||
{ result: prepared },
|
||||
[
|
||||
{
|
||||
disposition: 'preserve_disabled',
|
||||
reason: 'reviewed_disabled_preservation',
|
||||
},
|
||||
],
|
||||
'reviewer-reject',
|
||||
);
|
||||
for (const auth of [
|
||||
{ reviewerId: 'another-owner', assurance: 'local_console' },
|
||||
{ reviewerId: 'review-owner', assurance: 'password' },
|
||||
]) {
|
||||
const rejected = secretConfigDecisionCommitFixture(
|
||||
state,
|
||||
{ result: prepared, commandOptions: prepareCommand.options },
|
||||
valid.filePath,
|
||||
auth,
|
||||
);
|
||||
await assert.rejects(
|
||||
commitLocalReconciliationSecretConfigDecision(
|
||||
rejected.command,
|
||||
rejected.dependencies,
|
||||
),
|
||||
/requires the same recently strong authenticated User/,
|
||||
);
|
||||
assert.equal(rejected.authenticationCount(), 1);
|
||||
assert.equal(rejected.confirmationCount(), 0);
|
||||
assert.equal(rejected.databaseCloseCount(), 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('Secret/Config decision replays every publication boundary without repeated authentication', async (t) => {
|
||||
const prepareState = await plannedSecretConfigDecisionFixture(t, {
|
||||
suffix: 'decision-prepare-loss',
|
||||
secretConfigId: '00000000-0000-4000-8000-000000000431',
|
||||
});
|
||||
const prepareCommand = secretConfigDecisionPrepareCommand(
|
||||
prepareState,
|
||||
'019b0000-0000-7000-8000-000000000431',
|
||||
);
|
||||
await assert.rejects(
|
||||
prepareLocalReconciliationSecretConfigDecision(prepareCommand, {
|
||||
afterHeadPrepared() {
|
||||
throw new Error('secret config decision prepare response loss');
|
||||
},
|
||||
}),
|
||||
/secret config decision prepare response loss/,
|
||||
);
|
||||
const prepareReplay = await prepareLocalReconciliationSecretConfigDecision(
|
||||
prepareCommand,
|
||||
);
|
||||
assert.equal(
|
||||
prepareReplay.state,
|
||||
'reconciliation_secret_config_decision_prepared',
|
||||
);
|
||||
|
||||
for (const [window, tail] of [
|
||||
['authorization', '432'],
|
||||
['receipt', '433'],
|
||||
['seal', '434'],
|
||||
['head', '435'],
|
||||
]) {
|
||||
await t.test(window, async (subtest) => {
|
||||
const state = await plannedSecretConfigDecisionFixture(subtest, {
|
||||
suffix: `decision-${window}-loss`,
|
||||
secretConfigId: `00000000-0000-4000-8000-000000000${tail}`,
|
||||
});
|
||||
const decisionId = `019b0000-0000-7000-8000-000000000${tail}`;
|
||||
const selectedPrepareCommand = secretConfigDecisionPrepareCommand(
|
||||
state,
|
||||
decisionId,
|
||||
);
|
||||
const prepared = await prepareLocalReconciliationSecretConfigDecision(
|
||||
selectedPrepareCommand,
|
||||
);
|
||||
const review = secretConfigDecisionFile(
|
||||
state,
|
||||
{ result: prepared },
|
||||
[
|
||||
{
|
||||
disposition: 'preserve_disabled',
|
||||
reason: 'reviewed_disabled_preservation',
|
||||
},
|
||||
],
|
||||
`${window}-loss`,
|
||||
);
|
||||
const commit = secretConfigDecisionCommitFixture(
|
||||
state,
|
||||
{
|
||||
result: prepared,
|
||||
commandOptions: selectedPrepareCommand.options,
|
||||
},
|
||||
review.filePath,
|
||||
);
|
||||
const callback =
|
||||
window === 'authorization'
|
||||
? 'afterAuthorizationPublished'
|
||||
: window === 'receipt'
|
||||
? 'afterReceiptPublished'
|
||||
: window === 'seal'
|
||||
? 'afterTerminalSealed'
|
||||
: 'afterHeadAdvanced';
|
||||
await assert.rejects(
|
||||
commitLocalReconciliationSecretConfigDecision(commit.command, {
|
||||
...commit.dependencies,
|
||||
[callback]() {
|
||||
throw new Error(`secret config decision ${window} response loss`);
|
||||
},
|
||||
}),
|
||||
new RegExp(`secret config decision ${window} response loss`),
|
||||
);
|
||||
const replay = await commitLocalReconciliationSecretConfigDecision(
|
||||
commit.command,
|
||||
commit.dependencies,
|
||||
);
|
||||
assert.equal(replay.state, 'reconciliation_secret_config_reviewed');
|
||||
assert.equal(commit.authenticationCount(), 1);
|
||||
assert.equal(commit.confirmationCount(), 3);
|
||||
assert.equal(commit.databaseCloseCount(), 1);
|
||||
if (window === 'head') assert.equal(replay.status, 'existing');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Secret/Config decision can explicitly skip a ready candidate only into manual_required', async (t) => {
|
||||
const state = await plannedSecretConfigDecisionFixture(t, {
|
||||
suffix: 'decision-skip',
|
||||
secretConfigId: '00000000-0000-4000-8000-000000000436',
|
||||
});
|
||||
const prepareCommand = secretConfigDecisionPrepareCommand(
|
||||
state,
|
||||
'019b0000-0000-7000-8000-000000000436',
|
||||
);
|
||||
const prepared = await prepareLocalReconciliationSecretConfigDecision(
|
||||
prepareCommand,
|
||||
);
|
||||
const review = secretConfigDecisionFile(
|
||||
state,
|
||||
{ result: prepared },
|
||||
[{ disposition: 'skip', reason: 'operator_excluded' }],
|
||||
'skip',
|
||||
);
|
||||
const commit = secretConfigDecisionCommitFixture(
|
||||
state,
|
||||
{ result: prepared, commandOptions: prepareCommand.options },
|
||||
review.filePath,
|
||||
);
|
||||
const result = await commitLocalReconciliationSecretConfigDecision(
|
||||
commit.command,
|
||||
commit.dependencies,
|
||||
);
|
||||
assert.equal(result.outcome, 'manual_required');
|
||||
assert.equal(result.skippedCount, 1);
|
||||
assert.equal(result.preserveDisabledCount, 0);
|
||||
});
|
||||
|
||||
test('completion fence retains automation rollback backup while other domains remain manual', async (t) => {
|
||||
const state = await appliedAutomationFixture(t, {
|
||||
suffix: 'completion-fence',
|
||||
|
||||
@@ -2037,6 +2037,14 @@ function auditSourceImports(root, packagePath, findings) {
|
||||
'@qinglong/local-sqlite/rollout-safety',
|
||||
].includes(specifier)
|
||||
) &&
|
||||
!(
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
'src/deployment/reconciliation/application/secret-and-config/decisionCoordinator.ts' &&
|
||||
[
|
||||
'@qinglong/local-owner-console/authenticated-command',
|
||||
'@qinglong/local-sqlite/authentication-read',
|
||||
].includes(specifier)
|
||||
) &&
|
||||
!(
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
'src/deployment/reconciliation/application/automation/applyEvidence.ts' &&
|
||||
@@ -2725,6 +2733,17 @@ function auditSourceImports(root, packagePath, findings) {
|
||||
file: 'src/deployment/reconciliation/application/automation/applyCoordinator.ts',
|
||||
specifiers: ['@qinglong/runtime-core/security'],
|
||||
},
|
||||
{
|
||||
file: 'src/deployment/reconciliation/application/secret-and-config/decisionAuthorization.ts',
|
||||
specifiers: [
|
||||
'@qinglong/runtime-core/local-secret',
|
||||
'@qinglong/runtime-core/security',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'src/deployment/reconciliation/application/secret-and-config/decisionCoordinator.ts',
|
||||
specifiers: ['@qinglong/runtime-core/security'],
|
||||
},
|
||||
].some(
|
||||
({ file, specifiers }) =>
|
||||
path.relative(packageDirectory, filePath) === file &&
|
||||
|
||||
@@ -2280,6 +2280,73 @@ test('confines reconciliation Secret and Config inspection to its exact row plan
|
||||
);
|
||||
});
|
||||
|
||||
test('confines reconciliation Secret and Config decision authority to exact owners', (t) => {
|
||||
const root = fs.mkdtempSync(
|
||||
path.join(
|
||||
os.tmpdir(),
|
||||
'ql3-reconciliation-secret-config-decision-boundary-',
|
||||
),
|
||||
);
|
||||
const secretConfigDirectory = path.join(
|
||||
root,
|
||||
'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config',
|
||||
);
|
||||
fs.mkdirSync(secretConfigDirectory, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(secretConfigDirectory, 'decisionAuthorization.ts'),
|
||||
[
|
||||
"import type { Key } from '@qinglong/runtime-core/local-secret';",
|
||||
"import type { Principal } from '@qinglong/runtime-core/security';",
|
||||
].join('\n'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(secretConfigDirectory, 'decisionCoordinator.ts'),
|
||||
[
|
||||
"import { authenticate } from '@qinglong/local-owner-console/authenticated-command';",
|
||||
"import { database } from '@qinglong/local-sqlite/authentication-read';",
|
||||
"import type { Principal } from '@qinglong/runtime-core/security';",
|
||||
].join('\n'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(secretConfigDirectory, 'neighbor.ts'),
|
||||
[
|
||||
"import { authenticate } from '@qinglong/local-owner-console/authenticated-command';",
|
||||
"import { database } from '@qinglong/local-sqlite/authentication-read';",
|
||||
"import type { Key } from '@qinglong/runtime-core/local-secret';",
|
||||
"import type { Principal } from '@qinglong/runtime-core/security';",
|
||||
].join('\n'),
|
||||
);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const findings = [];
|
||||
auditSourceImports(root, 'packages/ql3-local-owner-cli', findings);
|
||||
assert.deepEqual(
|
||||
findings.map(({ code, file, specifier }) => ({ code, file, specifier })),
|
||||
[
|
||||
{
|
||||
code: 'FORBIDDEN_LOCAL_ADOPTION_CLI_AUTHORITY_IMPORT',
|
||||
file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/neighbor.ts',
|
||||
specifier: '@qinglong/local-owner-console/authenticated-command',
|
||||
},
|
||||
{
|
||||
code: 'FORBIDDEN_LOCAL_ADOPTION_CLI_AUTHORITY_IMPORT',
|
||||
file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/neighbor.ts',
|
||||
specifier: '@qinglong/local-sqlite/authentication-read',
|
||||
},
|
||||
{
|
||||
code: 'FORBIDDEN_PACKAGE_SOURCE_IMPORT',
|
||||
file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/neighbor.ts',
|
||||
specifier: '@qinglong/runtime-core/local-secret',
|
||||
},
|
||||
{
|
||||
code: 'FORBIDDEN_PACKAGE_SOURCE_IMPORT',
|
||||
file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/neighbor.ts',
|
||||
specifier: '@qinglong/runtime-core/security',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('deleted Owner ceremony package names remain dependency tombstones', (t) => {
|
||||
const root = fixture(
|
||||
t,
|
||||
|
||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 178,
|
||||
sourceFiles: 184,
|
||||
rootSourceFiles: 1,
|
||||
rootSourceLines: 50,
|
||||
nestedSourceFiles: 177,
|
||||
nestedSourceFiles: 183,
|
||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user