diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index df2379b0..781e9051 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -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。 diff --git a/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md b/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md index d87f90a7..2f4d241e 100644 --- a/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md +++ b/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md @@ -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 固定写入 `//{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。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 061d4c0f..fa31d17d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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) | ## 规则 diff --git a/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts b/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts index c4e2d060..4a3bbb6a 100644 --- a/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts @@ -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 { 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')) || diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts index 9dc1051b..60d9a355 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts @@ -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, diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts index ab0063d9..5bea6824 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts @@ -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 --command-file /absolute/private-command.json'; + 'Usage: ql3-local-deploy --command-file /absolute/private-command.json'; async function main(argv: readonly string[]): Promise { if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { @@ -106,6 +109,9 @@ async function main(argv: readonly string[]): Promise { 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 { ? 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' diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionAuthorization.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionAuthorization.ts new file mode 100644 index 00000000..48f5ea42 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionAuthorization.ts @@ -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; + 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; + 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; + readonly header: Readonly; +} + +export interface LocalReconciliationSecretConfigAuthorizationScope { + readonly evidence: Readonly; + readonly decisions: readonly Readonly[]; +} + +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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + configurationError(`${label} must be an object`); + } + const record = value as Record; + 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 { + 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 { + 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; + 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 { + 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 { + return normalizeHeader({ + schemaVersion: 1, + kind: HEADER_KIND, + ...value, + }); +} + +export async function publishLocalReconciliationSecretConfigAuthorization( + options: Readonly<{ + targetPath: string; + stagePath: string; + maxBytes: number; + header: Readonly; + keyProvider: LocalReconciliationReviewIssuerKeyringFileProvider; + writeDecisions: ( + append: ( + decision: Readonly, + ) => void, + ) => Readonly<{ + decisionFileDigest: string; + confirmDecisionFileAuthority(): void; + }>; + confirmAuthority(): void | Promise; + }>, +): Promise> { + 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 = 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> { + 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); + } +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionContract.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionContract.ts new file mode 100644 index 00000000..0f0b2c0c --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionContract.ts @@ -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; + 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; + 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; + 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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + configurationError(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + 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 { + 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 { + 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 { + 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 { + 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', + ), + }), + }); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionCoordinator.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionCoordinator.ts new file mode 100644 index 00000000..51eb59b7 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionCoordinator.ts @@ -0,0 +1,1217 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { readPrivateLocalCommandFile } from '@qinglong/local-command-file'; +import { + establishAuthenticatedLocalCommand, + type AuthenticatedLocalCommand, +} from '@qinglong/local-owner-console/authenticated-command'; +import { openLocalSqliteAuthenticationReadDatabase } from '@qinglong/local-sqlite/authentication-read'; +import type { SecurityPrincipal } from '@qinglong/runtime-core/security'; + +import { currentIdentity } from '../../../foundation/contract'; +import { LocalDeploymentConfigurationError } from '../../../foundation/error'; +import { + ensurePrivateDirectory, + preflightPublishedFile, + publishExactFile, + validatePrivateDirectory, +} from '../../../foundation/files'; +import { + advanceLocalCutoverInstanceHead, + readLocalCutoverInstanceHead, + type LocalCutoverInstanceHead, +} from '../../../cutover/instanceLineage'; +import { cutoverDigest } from '../../../cutover/targetEvidence'; +import { readLocalReconciliationCaptureIntent } from '../../preparation'; +import { readLocalReconciliationPlanTerminal } from '../../planning/preparation'; +import { + ensureLocalReconciliationReviewIssuerKeyring, + LocalReconciliationReviewIssuerKeyringFileProvider, +} from '../../review/issuerKeyring'; +import { + readLocalReconciliationApplicationTerminal, + type LocalReconciliationApplicationTerminal, +} from '../coordinator'; +import { + buildLocalReconciliationSecretConfigAuthorizationHeader, + publishLocalReconciliationSecretConfigAuthorization, + verifyLocalReconciliationSecretConfigAuthorization, + type LocalReconciliationSecretConfigAuthorizationScope, +} from './decisionAuthorization'; +import { + normalizeLocalReconciliationSecretConfigDecisionCommitCommand, + normalizeLocalReconciliationSecretConfigDecisionPrepareCommand, + normalizeLocalReconciliationSecretConfigDecisionVerifyCommand, + type LocalReconciliationSecretConfigDecisionCommitCommand, + type LocalReconciliationSecretConfigDecisionPrepareCommand, + type LocalReconciliationSecretConfigDecisionPrepareResult, + type LocalReconciliationSecretConfigDecisionTerminalResult, +} from './decisionContract'; +import { + buildLocalReconciliationSecretConfigDecisionIntent, + buildLocalReconciliationSecretConfigDecisionReceipt, + localReconciliationSecretConfigDecisionEvidenceContents, + normalizeLocalReconciliationSecretConfigDecisionIntent, + normalizeLocalReconciliationSecretConfigDecisionReceipt, + type LocalReconciliationSecretConfigDecisionIntent, + type LocalReconciliationSecretConfigDecisionReceipt, +} from './decisionEvidence'; +import { + assertLocalReconciliationSecretConfigDecisionMatchesRequirement, + MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_BYTES, + MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_BYTES, + withLocalReconciliationSecretConfigDecisionFile, + type LocalReconciliationSecretConfigDecision, +} from './decisionFile'; +import { + createLocalReconciliationSecretConfigDecisionRequirementFactory, + readLocalReconciliationSecretConfigPlanHeader, + type LocalReconciliationSecretConfigDecisionRequirement, +} from './planReader'; +import type { LocalReconciliationSecretConfigPlanHeader } from './rowPlan'; +import { + readLocalReconciliationSecretConfigTerminal, + type LocalReconciliationSecretConfigTerminal, +} from './coordinator'; + +const MAX_AUTHENTICATION_AGE_MS = 5 * 60 * 1_000; +const COMMIT_CLOCK_SKEW_MS = 60_000; +const MAX_TERMINAL_JSON_BYTES = 64 * 1024; + +interface DecisionPaths { + readonly root: string; + readonly staging: string; + readonly intent: string; + readonly authorization: string; + readonly authorizationStage: string; + readonly receipt: string; +} + +interface DecisionContext { + readonly secretConfig: Readonly; + readonly planHeader: Readonly; + readonly application: Readonly; + readonly planTerminal: ReturnType; +} + +type AuthenticationDatabase = Awaited< + ReturnType +>; + +export interface LocalReconciliationSecretConfigDecisionDependencies { + readonly openAuthenticationDatabase?: typeof openLocalSqliteAuthenticationReadDatabase; + readonly authenticate?: typeof establishAuthenticatedLocalCommand; + readonly now?: () => number; + readonly afterHeadPrepared?: () => void; + readonly afterAuthorizationPublished?: () => void; + readonly afterReceiptPublished?: () => void; + readonly afterTerminalSealed?: () => void; + readonly afterHeadAdvanced?: () => void; +} + +export interface LocalReconciliationSecretConfigDecisionTerminal { + readonly intent: Readonly; + readonly receipt: Readonly; + readonly authorization: Readonly; + readonly context: Readonly; + readonly reviewer: Readonly; + readonly head: Readonly; +} + +function configurationError(message: string, cause?: unknown): never { + throw new LocalDeploymentConfigurationError( + `reconciliation secret config decision ${message}`, + { cause }, + ); +} + +function paths( + decisionRoot: string, + secretConfigId: string, +): Readonly { + const root = path.join(decisionRoot, secretConfigId); + const staging = path.join(root, 'staging'); + return Object.freeze({ + root, + staging, + intent: path.join(root, 'intent.json'), + authorization: path.join(root, 'authorization.ndjson'), + authorizationStage: path.join(staging, 'authorization.ndjson.stage'), + receipt: path.join(root, 'receipt.json'), + }); +} + +function validateDirectory( + directory: string, + uid: number, + modes: readonly number[], + label: string, +): number { + let stat: fs.Stats; + try { + stat = fs.lstatSync(directory); + } catch (error) { + return configurationError(`${label} is unavailable`, error); + } + const mode = stat.mode & 0o777; + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + stat.uid !== uid || + !modes.includes(mode) || + fs.realpathSync(directory) !== directory + ) { + configurationError(`${label} identity is invalid`); + } + return mode; +} + +function validateCatalog( + selected: Readonly, + terminal: boolean, +): void { + const allowed = new Set([ + 'authorization.ndjson', + 'intent.json', + 'receipt.json', + 'staging', + ...(!terminal + ? ['.intent.json.ql3-deploy-stage', '.receipt.json.ql3-deploy-stage'] + : []), + ]); + for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) { + if (!allowed.has(entry.name) || entry.isSymbolicLink()) { + configurationError('decision root contains unknown material'); + } + } + const stagingEntries = fs.readdirSync(selected.staging); + if ( + terminal + ? stagingEntries.length !== 0 + : stagingEntries.some((entry) => entry !== 'authorization.ndjson.stage') + ) { + configurationError('decision staging contains unknown material'); + } +} + +function terminalJson( + filePath: string, + uid: number, + allowedModes: readonly number[], +): unknown { + let descriptor: number | undefined; + let bytes: Buffer | undefined; + try { + const before = fs.lstatSync(filePath, { bigint: true }); + if ( + !before.isFile() || + before.isSymbolicLink() || + Number(before.uid) !== uid || + !allowedModes.includes(Number(before.mode) & 0o777) || + before.nlink !== 1n || + before.size < 2n || + before.size > BigInt(MAX_TERMINAL_JSON_BYTES) + ) { + configurationError('terminal JSON 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('terminal JSON 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('terminal JSON changed while reading'); + } + return JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(bytes), + ) as unknown; + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + return configurationError('terminal JSON cannot be read', error); + } finally { + bytes?.fill(0); + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function readIntent( + selected: Readonly, + uid: number, + allowedModes: readonly number[], +): Readonly { + return normalizeLocalReconciliationSecretConfigDecisionIntent( + terminalJson(selected.intent, uid, allowedModes), + ); +} + +function readReceipt( + selected: Readonly, + uid: number, + allowedModes: readonly number[], +): Readonly { + return normalizeLocalReconciliationSecretConfigDecisionReceipt( + terminalJson(selected.receipt, uid, allowedModes), + ); +} + +async function context( + options: Readonly< + LocalReconciliationSecretConfigDecisionPrepareCommand['options'] + >, + secretConfigId: string, + uid: number, +): Promise> { + const secretConfig = readLocalReconciliationSecretConfigTerminal( + options.secretConfigRoot, + secretConfigId, + uid, + ); + const application = await readLocalReconciliationApplicationTerminal( + options.applicationRoot, + secretConfig.receipt.applicationId, + uid, + ); + const planTerminal = readLocalReconciliationPlanTerminal( + application.intent.command.options.planRoot, + application.review.intent.command.request.planId, + uid, + ); + const planHeader = readLocalReconciliationSecretConfigPlanHeader( + secretConfig.planPath, + secretConfig.receipt, + uid, + ); + if ( + application.intent.command.options.deploymentRoot !== + options.deploymentRoot || + application.intent.command.options.applicationRoot !== + options.applicationRoot || + secretConfig.receipt.applicationPlanDigest !== + application.plan.applicationPlanDigest || + planTerminal.plan.planDigest !== + application.review.intent.command.request.expectedPlanDigest + ) { + configurationError('plan is detached from application authority'); + } + return Object.freeze({ secretConfig, planHeader, application, planTerminal }); +} + +function intentBinding( + intent: Readonly, + selected: Readonly, +): void { + const plan = selected.secretConfig.receipt; + if ( + intent.command.request.secretConfigId !== plan.secretConfigId || + intent.command.request.expectedSecretConfigPlanDigest !== + plan.secretConfigPlanDigest || + intent.applicationId !== selected.application.plan.applicationId || + intent.applicationPlanDigest !== + selected.application.plan.applicationPlanDigest || + intent.profile !== selected.application.intent.profile || + intent.projectId !== selected.planHeader.projectId || + intent.secretConfigPlanDigest !== plan.secretConfigPlanDigest || + intent.candidateSetDigest !== plan.candidateSetDigest || + intent.bundleDigest !== selected.planTerminal.bundle.receipt.bundleDigest || + intent.bundleFingerprintDigest !== + selected.planTerminal.bundle.fingerprintDigest || + intent.instanceId !== selected.application.intent.instanceId || + intent.cutoverId !== selected.application.intent.cutoverId || + intent.activationDigest !== selected.application.intent.activationDigest || + intent.generation !== selected.application.intent.generation + ) { + configurationError('decision intent binding drifted'); + } +} + +function prepareResult( + status: 'prepared' | 'existing', + intent: Readonly, + head: Readonly, +): Readonly { + return Object.freeze({ + schemaVersion: 1, + operation: 'local.deployment.reconciliation.secret-config.decision.prepare', + status, + state: 'reconciliation_secret_config_decision_prepared', + decisionId: intent.command.request.decisionId, + secretConfigId: intent.command.request.secretConfigId, + preparationDigest: intent.preparationDigest, + instanceHeadDigest: head.headDigest, + }); +} + +function terminalResult( + operation: LocalReconciliationSecretConfigDecisionTerminalResult['operation'], + status: LocalReconciliationSecretConfigDecisionTerminalResult['status'], + receipt: Readonly, + head: Readonly, +): Readonly { + return Object.freeze({ + schemaVersion: 1, + operation, + status, + state: 'reconciliation_secret_config_reviewed', + decisionId: receipt.decisionId, + secretConfigId: receipt.secretConfigId, + decisionDigest: receipt.decisionDigest, + signedDecisionSetDigest: receipt.signedDecisionSetDigest, + candidateCount: receipt.candidateCount, + applyBindingCount: receipt.applyBindingCount, + preserveDisabledCount: receipt.preserveDisabledCount, + skippedCount: receipt.skippedCount, + outcome: receipt.outcome, + instanceHeadDigest: head.headDigest, + }); +} + +function advanceHead( + intent: Readonly, + state: + | 'reconciliation_secret_config_decision_prepared' + | 'reconciliation_secret_config_reviewed', + sourceRecordDigest: string, + requestedAtMs: number, + uid: number, +): Readonly { + return advanceLocalCutoverInstanceHead( + { + options: { deploymentRoot: intent.command.options.deploymentRoot }, + request: { + cutoverId: intent.cutoverId, + profile: intent.profile, + instanceId: intent.instanceId, + expectedActivationDigest: intent.activationDigest, + requestedAtMs, + }, + }, + uid, + state, + intent.generation, + sourceRecordDigest, + ); +} + +export async function prepareLocalReconciliationSecretConfigDecision( + value: unknown, + dependencies: LocalReconciliationSecretConfigDecisionDependencies = {}, +): Promise> { + const command = + normalizeLocalReconciliationSecretConfigDecisionPrepareCommand(value); + const uid = currentIdentity().uid; + for (const [directory, label] of [ + [command.options.deploymentRoot, 'deploymentRoot'], + [command.options.applicationRoot, 'applicationRoot'], + [command.options.secretConfigRoot, 'secretConfigRoot'], + [command.options.secretConfigDecisionRoot, 'secretConfigDecisionRoot'], + ] as const) { + validatePrivateDirectory(directory, uid, label); + } + const selectedContext = await context( + command.options, + command.request.secretConfigId, + uid, + ); + const plan = selectedContext.secretConfig.receipt; + if ( + plan.secretConfigPlanDigest !== + command.request.expectedSecretConfigPlanDigest || + plan.outcome !== 'ready' || + plan.eligibleBindingCount + plan.eligiblePreservationCount < 1 || + plan.targetConflictCount !== 0 || + plan.manualRowCount !== 0 || + plan.manualGroupCount !== 0 || + plan.unadaptedLegacyConfigCount !== 0 + ) { + configurationError('only a ready non-empty plan can be reviewed'); + } + const intent = buildLocalReconciliationSecretConfigDecisionIntent({ + command, + applicationId: selectedContext.application.plan.applicationId, + applicationPlanDigest: + selectedContext.application.plan.applicationPlanDigest, + profile: selectedContext.application.intent.profile, + projectId: selectedContext.planHeader.projectId, + secretConfigPlanDigest: plan.secretConfigPlanDigest, + candidateSetDigest: plan.candidateSetDigest, + bundleDigest: selectedContext.planTerminal.bundle.receipt.bundleDigest, + bundleFingerprintDigest: + selectedContext.planTerminal.bundle.fingerprintDigest, + instanceId: selectedContext.application.intent.instanceId, + cutoverId: selectedContext.application.intent.cutoverId, + activationDigest: selectedContext.application.intent.activationDigest, + generation: selectedContext.application.intent.generation, + }); + intentBinding(intent, selectedContext); + let head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + uid, + ); + if ( + (head.state === 'reconciliation_secret_config_planned' && + (head.headDigest !== command.request.expectedHeadDigest || + head.sourceRecordDigest !== plan.secretConfigPlanDigest)) || + (head.state === 'reconciliation_secret_config_decision_prepared' && + head.sourceRecordDigest !== intent.preparationDigest) || + (head.state !== 'reconciliation_secret_config_planned' && + head.state !== 'reconciliation_secret_config_decision_prepared') + ) { + configurationError('decision prepare lost instance head compare-and-swap'); + } + const selectedPaths = paths( + command.options.secretConfigDecisionRoot, + command.request.secretConfigId, + ); + ensurePrivateDirectory( + selectedPaths.root, + uid, + 'secretConfigDecisionDirectory', + ); + ensurePrivateDirectory( + selectedPaths.staging, + uid, + 'secretConfigDecisionStaging', + ); + validateCatalog(selectedPaths, false); + const contents = + localReconciliationSecretConfigDecisionEvidenceContents(intent); + preflightPublishedFile( + selectedPaths.intent, + contents, + 0o600, + uid, + 'Secret/Config decision intent', + ); + head = + head.state === 'reconciliation_secret_config_decision_prepared' + ? head + : advanceHead( + intent, + 'reconciliation_secret_config_decision_prepared', + intent.preparationDigest, + command.request.preparedAtMs, + uid, + ); + dependencies.afterHeadPrepared?.(); + const status = publishExactFile( + selectedPaths.intent, + contents, + 0o600, + uid, + 'Secret/Config decision intent', + ); + validateCatalog(selectedPaths, false); + return prepareResult(status, intent, head); +} + +function validateCommitBinding( + command: Readonly, + intent: Readonly, +): void { + const prepared = intent.command; + if ( + prepared.options.deploymentRoot !== command.options.deploymentRoot || + prepared.options.applicationRoot !== command.options.applicationRoot || + prepared.options.secretConfigRoot !== command.options.secretConfigRoot || + prepared.options.secretConfigDecisionRoot !== + command.options.secretConfigDecisionRoot || + prepared.options.allowRootService !== command.options.allowRootService || + prepared.request.decisionId !== command.request.decisionId || + prepared.request.secretConfigId !== command.request.secretConfigId || + intent.preparationDigest !== command.request.expectedPreparationDigest + ) { + configurationError('commit command is detached from prepared intent'); + } +} + +function strongReviewer( + authenticated: Readonly, + original: Readonly< + DecisionContext['application']['review']['authorization']['header']['reviewer'] + >, + committedAtMs: number, +): Readonly { + const principal = authenticated.principal; + if ( + principal.subject.type !== 'user' || + original.subject.type !== 'user' || + principal.subject.id !== original.subject.id || + !['hardware', 'local_console', 'multi_factor'].includes( + principal.assurance, + ) || + principal.authenticatedAtMs > committedAtMs || + committedAtMs - principal.authenticatedAtMs > MAX_AUTHENTICATION_AGE_MS || + principal.expiresAtMs <= committedAtMs + ) { + configurationError( + 'decision commit requires the same recently strong authenticated User', + ); + } + return principal; +} + +function requirementFactory( + selected: Readonly, + uid: number, +): () => Iterable { + return createLocalReconciliationSecretConfigDecisionRequirementFactory( + selected.secretConfig.planPath, + selected.secretConfig.receipt, + uid, + ); +} + +function bindDecisions( + decisions: readonly Readonly[], + openRequirements: () => Iterable, +): void { + const requirements = openRequirements()[Symbol.iterator](); + let index = 0; + try { + for (; index < decisions.length; index += 1) { + const next = requirements.next(); + if (next.done) + configurationError('decision set contains an extra candidate'); + assertLocalReconciliationSecretConfigDecisionMatchesRequirement( + decisions[index]!, + next.value, + ); + } + if (!requirements.next().done) { + configurationError('decision set is missing a candidate'); + } + } finally { + requirements.return?.(); + } +} + +function authorizationMaxBytes(profile: 'edge' | 'standalone'): number { + return profile === 'edge' + ? MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_BYTES + : MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_DECISION_BYTES; +} + +function expectedAuthorization( + intent: Readonly, + decisionFileDigest: string, + preparedHeadDigest: string, +) { + return Object.freeze({ + decisionId: intent.command.request.decisionId, + secretConfigId: intent.command.request.secretConfigId, + profile: intent.profile, + secretConfigPlanDigest: intent.secretConfigPlanDigest, + candidateSetDigest: intent.candidateSetDigest, + applicationPlanDigest: intent.applicationPlanDigest, + preparationDigest: intent.preparationDigest, + preparedHeadDigest, + bundleDigest: intent.bundleDigest, + bundleFingerprintDigest: intent.bundleFingerprintDigest, + decisionFileDigest, + }); +} + +async function verifyAuthorization( + selectedPaths: Readonly, + intent: Readonly, + selected: Readonly, + decisionFileDigest: string, + preparedHeadDigest: string, + uid: number, + allowedModes: readonly number[], +): Promise> { + const keyProvider = new LocalReconciliationReviewIssuerKeyringFileProvider( + selected.application.intent.command.options.issuerKeyringPath, + ); + const scope = await verifyLocalReconciliationSecretConfigAuthorization( + selectedPaths.authorization, + { + maxBytes: authorizationMaxBytes(intent.profile), + allowedModes, + keyProvider, + expected: expectedAuthorization( + intent, + decisionFileDigest, + preparedHeadDigest, + ), + }, + ); + bindDecisions(scope.decisions, requirementFactory(selected, uid)); + return scope; +} + +async function authorization( + command: Readonly, + intent: Readonly, + selectedPaths: Readonly, + selected: Readonly, + dependencies: LocalReconciliationSecretConfigDecisionDependencies, + uid: number, +): Promise> { + const capture = readLocalReconciliationCaptureIntent( + selected.application.intent.command.options.captureRoot, + selected.planTerminal.plan.captureId, + ); + if ( + capture.command.request.targetDatabasePath !== + command.options.targetDatabasePath + ) { + configurationError('authentication database is detached from capture'); + } + const openRequirements = requirementFactory(selected, uid); + const decisionFile = withLocalReconciliationSecretConfigDecisionFile( + command.request.decisionFilePath, + { + decisionId: command.request.decisionId, + profile: intent.profile, + secretConfigPlanDigest: intent.secretConfigPlanDigest, + preparationDigest: intent.preparationDigest, + }, + (cursor) => { + const requirements = openRequirements()[Symbol.iterator](); + const decisions: Readonly[] = []; + try { + for (;;) { + const selectedDecision = cursor.next(); + const selectedRequirement = requirements.next(); + if (!selectedDecision || selectedRequirement.done) { + if ((selectedDecision === null) !== selectedRequirement.done) { + configurationError('decision and candidate counts differ'); + } + return Object.freeze(decisions); + } + assertLocalReconciliationSecretConfigDecisionMatchesRequirement( + selectedDecision, + selectedRequirement.value, + ); + decisions.push(selectedDecision); + } + } finally { + requirements.return?.(); + } + }, + ); + if (decisionFile.result.length < 1) { + configurationError('decision file must contain at least one candidate'); + } + const keyringPath = + selected.application.intent.command.options.issuerKeyringPath; + ensureLocalReconciliationReviewIssuerKeyring(keyringPath); + const keyProvider = new LocalReconciliationReviewIssuerKeyringFileProvider( + keyringPath, + ); + if (fs.existsSync(selectedPaths.authorization)) { + const recovered = await verifyAuthorization( + selectedPaths, + intent, + selected, + decisionFile.evidence.fileDigest, + command.request.expectedHeadDigest, + uid, + [0o600, 0o400], + ); + if ( + recovered.decisions.length !== decisionFile.result.length || + recovered.decisions.some( + (value, index) => + JSON.stringify(value) !== JSON.stringify(decisionFile.result[index]), + ) + ) { + configurationError('review decision differs from signed authorization'); + } + decisionFile.confirmIdentity(); + return recovered; + } + const now = (dependencies.now ?? Date.now)(); + if ( + !Number.isSafeInteger(now) || + Math.abs(now - command.request.committedAtMs) > COMMIT_CLOCK_SKEW_MS + ) { + configurationError('commit timestamp is outside its clock window'); + } + const openDatabase = + dependencies.openAuthenticationDatabase ?? + openLocalSqliteAuthenticationReadDatabase; + const database: AuthenticationDatabase = await openDatabase({ + databasePath: command.options.targetDatabasePath, + profile: intent.profile, + ...(command.options.busyTimeoutMs === undefined + ? {} + : { busyTimeoutMs: command.options.busyTimeoutMs }), + }); + try { + const authenticate = + dependencies.authenticate ?? establishAuthenticatedLocalCommand; + const authenticated = await authenticate(database, { + deploymentRoot: command.options.deploymentRoot, + databasePath: command.options.targetDatabasePath, + ownerPepperKeyringDirectory: command.options.ownerPepperKeyringDirectory, + credentialFilePath: command.options.credentialFilePath, + authenticationNamespace: 'local_reconciliation_secret_config', + now: () => command.request.committedAtMs, + }); + const reviewer = strongReviewer( + authenticated, + selected.application.review.authorization.header.reviewer, + command.request.committedAtMs, + ); + const header = buildLocalReconciliationSecretConfigAuthorizationHeader({ + decisionId: command.request.decisionId, + secretConfigId: command.request.secretConfigId, + profile: intent.profile, + secretConfigPlanDigest: intent.secretConfigPlanDigest, + candidateSetDigest: intent.candidateSetDigest, + applicationPlanDigest: intent.applicationPlanDigest, + preparationDigest: intent.preparationDigest, + preparedHeadDigest: command.request.expectedHeadDigest, + bundleDigest: intent.bundleDigest, + bundleFingerprintDigest: intent.bundleFingerprintDigest, + reviewer, + issuedAtMs: command.request.committedAtMs, + expiresAtMs: + command.request.committedAtMs + command.request.authorizationLifetimeMs, + }); + await publishLocalReconciliationSecretConfigAuthorization({ + targetPath: selectedPaths.authorization, + stagePath: selectedPaths.authorizationStage, + maxBytes: authorizationMaxBytes(intent.profile), + header, + keyProvider, + writeDecisions(append) { + for (const selectedDecision of decisionFile.result) { + append(selectedDecision); + } + return Object.freeze({ + decisionFileDigest: decisionFile.evidence.fileDigest, + confirmDecisionFileAuthority: decisionFile.confirmIdentity, + }); + }, + async confirmAuthority() { + const head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + uid, + ); + if ( + head.state !== 'reconciliation_secret_config_decision_prepared' || + head.headDigest !== command.request.expectedHeadDigest || + head.sourceRecordDigest !== intent.preparationDigest + ) { + configurationError('decision authority lost prepared head'); + } + await authenticated.confirm(); + }, + }); + } finally { + await database.close(); + } + return verifyAuthorization( + selectedPaths, + intent, + selected, + decisionFile.evidence.fileDigest, + command.request.expectedHeadDigest, + uid, + [0o600], + ); +} + +function buildReceipt( + intent: Readonly, + authorizationScope: Readonly, +): Readonly { + const evidence = authorizationScope.evidence; + return buildLocalReconciliationSecretConfigDecisionReceipt({ + decisionId: intent.command.request.decisionId, + secretConfigId: intent.command.request.secretConfigId, + secretConfigPlanDigest: intent.secretConfigPlanDigest, + candidateSetDigest: intent.candidateSetDigest, + applicationPlanDigest: intent.applicationPlanDigest, + preparedHeadDigest: evidence.header.preparedHeadDigest, + authorizationDigest: evidence.authorizationDigest, + signedDecisionSetDigest: evidence.decisionSetDigest, + decisionFileDigest: evidence.decisionFileDigest, + reviewerDigest: cutoverDigest({ + subject: evidence.header.reviewer.subject, + authenticationId: evidence.header.reviewer.authenticationId, + authenticatedAtMs: evidence.header.reviewer.authenticatedAtMs, + assurance: evidence.header.reviewer.assurance, + }), + candidateCount: evidence.decisionCount, + applyBindingCount: evidence.dispositionCounts.apply_active_binding, + preserveDisabledCount: evidence.dispositionCounts.preserve_disabled, + skippedCount: evidence.dispositionCounts.skip, + outcome: + evidence.dispositionCounts.skip === 0 ? 'ready' : 'manual_required', + issuedAtMs: evidence.header.issuedAtMs, + expiresAtMs: evidence.header.expiresAtMs, + }); +} + +function validateReceiptBinding( + receipt: Readonly, + intent: Readonly, + authorizationScope: Readonly, +): void { + const expected = buildReceipt(intent, authorizationScope); + if (expected.decisionDigest !== receipt.decisionDigest) { + configurationError('terminal receipt is detached from authorization'); + } +} + +function sealFile(filePath: string, uid: number): void { + let descriptor: number | undefined; + try { + const before = fs.lstatSync(filePath, { bigint: true }); + if ( + !before.isFile() || + before.isSymbolicLink() || + Number(before.uid) !== uid || + ![0o600, 0o400].includes(Number(before.mode) & 0o777) || + before.nlink !== 1n + ) { + configurationError('terminal file cannot be sealed'); + } + 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 + ) { + configurationError('terminal file changed while sealing'); + } + if ((Number(opened.mode) & 0o777) !== 0o400) { + fs.fchmodSync(descriptor, 0o400); + } + fs.fsyncSync(descriptor); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function sealDirectory(directory: string, uid: number): void { + const mode = validateDirectory(directory, uid, [0o700, 0o500], 'directory'); + const descriptor = fs.openSync(directory, fs.constants.O_RDONLY); + try { + if (mode !== 0o500) fs.fchmodSync(descriptor, 0o500); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function sealTerminal(selected: Readonly, uid: number): void { + sealFile(selected.intent, uid); + sealFile(selected.authorization, uid); + sealFile(selected.receipt, uid); + sealDirectory(selected.staging, uid); + sealDirectory(selected.root, uid); + validateCatalog(selected, true); +} + +export async function commitLocalReconciliationSecretConfigDecision( + value: unknown, + dependencies: LocalReconciliationSecretConfigDecisionDependencies = {}, +): Promise> { + const command = + normalizeLocalReconciliationSecretConfigDecisionCommitCommand(value); + const uid = currentIdentity().uid; + for (const [directory, label] of [ + [command.options.deploymentRoot, 'deploymentRoot'], + [command.options.applicationRoot, 'applicationRoot'], + [command.options.secretConfigRoot, 'secretConfigRoot'], + [command.options.secretConfigDecisionRoot, 'secretConfigDecisionRoot'], + ] as const) { + validatePrivateDirectory(directory, uid, label); + } + const selectedPaths = paths( + command.options.secretConfigDecisionRoot, + command.request.secretConfigId, + ); + validateDirectory(selectedPaths.root, uid, [0o700, 0o500], 'decision root'); + validateDirectory( + selectedPaths.staging, + uid, + [0o700, 0o500], + 'decision staging', + ); + const intent = readIntent(selectedPaths, uid, [0o600, 0o400]); + validateCommitBinding(command, intent); + const selectedContext = await context( + intent.command.options, + intent.command.request.secretConfigId, + uid, + ); + intentBinding(intent, selectedContext); + let head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + uid, + ); + if (fs.existsSync(selectedPaths.receipt)) { + validateCatalog(selectedPaths, false); + const receipt = readReceipt(selectedPaths, uid, [0o600, 0o400]); + if ( + receipt.decisionId !== command.request.decisionId || + receipt.secretConfigId !== command.request.secretConfigId || + receipt.preparedHeadDigest !== command.request.expectedHeadDigest || + receipt.issuedAtMs !== command.request.committedAtMs || + receipt.expiresAtMs !== + command.request.committedAtMs + command.request.authorizationLifetimeMs + ) { + configurationError('terminal receipt is not an exact command replay'); + } + const authorizationScope = await verifyAuthorization( + selectedPaths, + intent, + selectedContext, + receipt.decisionFileDigest, + receipt.preparedHeadDigest, + uid, + [0o600, 0o400], + ); + validateReceiptBinding(receipt, intent, authorizationScope); + const existing = head.state === 'reconciliation_secret_config_reviewed'; + if ( + (!existing && + (head.state !== 'reconciliation_secret_config_decision_prepared' || + head.sourceRecordDigest !== intent.preparationDigest)) || + (existing && + (head.previousHeadDigest !== receipt.preparedHeadDigest || + head.sourceRecordDigest !== receipt.decisionDigest)) + ) { + configurationError('terminal receipt lost instance head binding'); + } + sealTerminal(selectedPaths, uid); + dependencies.afterTerminalSealed?.(); + head = existing + ? head + : advanceHead( + intent, + 'reconciliation_secret_config_reviewed', + receipt.decisionDigest, + receipt.issuedAtMs, + uid, + ); + dependencies.afterHeadAdvanced?.(); + return terminalResult( + command.operation, + existing ? 'existing' : 'prepared', + receipt, + head, + ); + } + if ( + head.state !== 'reconciliation_secret_config_decision_prepared' || + head.headDigest !== command.request.expectedHeadDigest || + head.sourceRecordDigest !== intent.preparationDigest + ) { + configurationError('decision commit lost prepared head compare-and-swap'); + } + validateCatalog(selectedPaths, false); + const authorizationScope = await authorization( + command, + intent, + selectedPaths, + selectedContext, + dependencies, + uid, + ); + dependencies.afterAuthorizationPublished?.(); + const receipt = buildReceipt(intent, authorizationScope); + publishExactFile( + selectedPaths.receipt, + localReconciliationSecretConfigDecisionEvidenceContents(receipt), + 0o600, + uid, + 'Secret/Config decision receipt', + ); + dependencies.afterReceiptPublished?.(); + validateReceiptBinding(receipt, intent, authorizationScope); + sealTerminal(selectedPaths, uid); + dependencies.afterTerminalSealed?.(); + head = advanceHead( + intent, + 'reconciliation_secret_config_reviewed', + receipt.decisionDigest, + receipt.issuedAtMs, + uid, + ); + dependencies.afterHeadAdvanced?.(); + return terminalResult(command.operation, 'prepared', receipt, head); +} + +export async function verifyLocalReconciliationSecretConfigDecision( + value: unknown, +): Promise> { + const command = + normalizeLocalReconciliationSecretConfigDecisionVerifyCommand(value); + const uid = currentIdentity().uid; + for (const [directory, label] of [ + [command.options.deploymentRoot, 'deploymentRoot'], + [command.options.applicationRoot, 'applicationRoot'], + [command.options.secretConfigRoot, 'secretConfigRoot'], + [command.options.secretConfigDecisionRoot, 'secretConfigDecisionRoot'], + ] as const) { + validatePrivateDirectory(directory, uid, label); + } + const selectedPaths = paths( + command.options.secretConfigDecisionRoot, + command.request.secretConfigId, + ); + validateDirectory(selectedPaths.root, uid, [0o500], 'decision root'); + validateDirectory(selectedPaths.staging, uid, [0o500], 'decision staging'); + validateCatalog(selectedPaths, true); + const intent = readIntent(selectedPaths, uid, [0o400]); + const receipt = readReceipt(selectedPaths, uid, [0o400]); + if ( + intent.command.request.decisionId !== command.request.decisionId || + receipt.decisionId !== command.request.decisionId || + receipt.secretConfigId !== command.request.secretConfigId || + receipt.decisionDigest !== command.request.expectedDecisionDigest + ) { + configurationError('verify command is detached from terminal decision'); + } + const selectedContext = await context( + intent.command.options, + intent.command.request.secretConfigId, + uid, + ); + intentBinding(intent, selectedContext); + const authorizationScope = await verifyAuthorization( + selectedPaths, + intent, + selectedContext, + receipt.decisionFileDigest, + receipt.preparedHeadDigest, + uid, + [0o400], + ); + validateReceiptBinding(receipt, intent, authorizationScope); + const head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + uid, + ); + if ( + head.state !== 'reconciliation_secret_config_reviewed' || + head.previousHeadDigest !== receipt.preparedHeadDigest || + head.sourceRecordDigest !== receipt.decisionDigest + ) { + configurationError('terminal decision is detached from instance head'); + } + return terminalResult(command.operation, 'verified', receipt, head); +} + +export async function readLocalReconciliationSecretConfigDecisionTerminal( + options: Readonly< + LocalReconciliationSecretConfigDecisionPrepareCommand['options'] + >, + secretConfigId: string, + uid: number, +): Promise> { + for (const [directory, label] of [ + [options.deploymentRoot, 'deploymentRoot'], + [options.applicationRoot, 'applicationRoot'], + [options.secretConfigRoot, 'secretConfigRoot'], + [options.secretConfigDecisionRoot, 'secretConfigDecisionRoot'], + ] as const) { + validatePrivateDirectory(directory, uid, label); + } + const selectedPaths = paths(options.secretConfigDecisionRoot, secretConfigId); + validateDirectory(selectedPaths.root, uid, [0o500], 'decision root'); + validateDirectory(selectedPaths.staging, uid, [0o500], 'decision staging'); + validateCatalog(selectedPaths, true); + const intent = readIntent(selectedPaths, uid, [0o400]); + const receipt = readReceipt(selectedPaths, uid, [0o400]); + if ( + intent.command.request.secretConfigId !== secretConfigId || + receipt.secretConfigId !== secretConfigId || + receipt.decisionId !== intent.command.request.decisionId + ) { + configurationError('terminal decision identity drifted'); + } + const selectedContext = await context(options, secretConfigId, uid); + intentBinding(intent, selectedContext); + const authorizationScope = await verifyAuthorization( + selectedPaths, + intent, + selectedContext, + receipt.decisionFileDigest, + receipt.preparedHeadDigest, + uid, + [0o400], + ); + validateReceiptBinding(receipt, intent, authorizationScope); + const head = readLocalCutoverInstanceHead( + options.deploymentRoot, + intent.instanceId, + uid, + ); + if ( + head.state !== 'reconciliation_secret_config_reviewed' || + head.previousHeadDigest !== receipt.preparedHeadDigest || + head.sourceRecordDigest !== receipt.decisionDigest + ) { + configurationError('terminal decision is detached from instance head'); + } + return Object.freeze({ + intent, + receipt, + authorization: authorizationScope, + context: selectedContext, + reviewer: authorizationScope.evidence.header.reviewer, + head, + }); +} + +export function prepareLocalReconciliationSecretConfigDecisionCommandFile( + filePath: string, + dependencies: LocalReconciliationSecretConfigDecisionDependencies = {}, +) { + return prepareLocalReconciliationSecretConfigDecision( + readPrivateLocalCommandFile(filePath), + dependencies, + ); +} + +export function commitLocalReconciliationSecretConfigDecisionCommandFile( + filePath: string, + dependencies: LocalReconciliationSecretConfigDecisionDependencies = {}, +) { + return commitLocalReconciliationSecretConfigDecision( + readPrivateLocalCommandFile(filePath), + dependencies, + ); +} + +export function verifyLocalReconciliationSecretConfigDecisionCommandFile( + filePath: string, +) { + return verifyLocalReconciliationSecretConfigDecision( + readPrivateLocalCommandFile(filePath), + ); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionEvidence.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionEvidence.ts new file mode 100644 index 00000000..b519ea62 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionEvidence.ts @@ -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; + 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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + configurationError(`${label} must be an object`); + } + const record = value as Record; + 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 { + 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 { + 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; +} + +export function buildLocalReconciliationSecretConfigDecisionReceipt( + input: Omit< + LocalReconciliationSecretConfigDecisionReceipt, + 'schema' | 'schemaVersion' | 'state' | 'decisionDigest' + >, +): Readonly { + 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 { + 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; +} + +export function localReconciliationSecretConfigDecisionEvidenceContents( + value: + | Readonly + | Readonly, +): string { + return `${JSON.stringify(value, null, 2)}\n`; +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionFile.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionFile.ts new file mode 100644 index 00000000..ab379bf5 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionFile.ts @@ -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; + next(): Readonly | 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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + configurationError(`${label} must be an object`); + } + const record = value as Record; + 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 { + 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; +} + +function decision( + value: unknown, +): Readonly { + 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; +} + +export function normalizeLocalReconciliationSecretConfigDecision( + value: unknown, +): Readonly { + return decision(value); +} + +export function assertLocalReconciliationSecretConfigDecisionMatchesRequirement( + selected: Readonly, + requirement: Readonly, +): 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( + filePath: string, + expected: Readonly<{ + decisionId: string; + profile: 'edge' | 'standalone'; + secretConfigPlanDigest: string; + preparationDigest: string; + }>, + consume: (cursor: LocalReconciliationSecretConfigDecisionCursor) => T, +): Readonly<{ + result: T; + evidence: Readonly; + 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; + try { + parsedHeader = header(parse(first, 'header'), expected); + } finally { + first.fill(0); + } + const cursor: LocalReconciliationSecretConfigDecisionCursor = Object.freeze( + { + header: parsedHeader, + next(): Readonly | 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); + } +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/planReader.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/planReader.ts new file mode 100644 index 00000000..f8b057d2 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/planReader.ts @@ -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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + configurationError(`${label} must be an object`); + } + const record = value as Record; + 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 { + 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 { + 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 | 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, +): Readonly { + 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; +} + +export function readLocalReconciliationSecretConfigPlanHeader( + filePath: string, + receipt: Readonly, + uid: number, +): Readonly { + 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, + uid: number, +): () => Iterable { + return () => + (function* (): Iterable { + 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); + } + })(); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts b/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts index 61f61118..217b4ce4 100644 --- a/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts +++ b/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts @@ -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 && diff --git a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs index b43e9a45..32d258ef 100644 --- a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs +++ b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs @@ -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', diff --git a/scripts/ql3-cluster-dependency-audit.cjs b/scripts/ql3-cluster-dependency-audit.cjs index 93cf0667..dcd17cb6 100644 --- a/scripts/ql3-cluster-dependency-audit.cjs +++ b/scripts/ql3-cluster-dependency-audit.cjs @@ -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 && diff --git a/test/back/ql3ClusterDependencyAudit.test.cjs b/test/back/ql3ClusterDependencyAudit.test.cjs index b7d1862d..0d7aa5ff 100644 --- a/test/back/ql3ClusterDependencyAudit.test.cjs +++ b/test/back/ql3ClusterDependencyAudit.test.cjs @@ -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, diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index b1365ef5..a44f6f5a 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -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' }, }, );