diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 65d11c58..f09e0d22 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -391,7 +391,7 @@ jobs: - name: Verify non-root identity and architecture env: IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} - EXPECTED: ${{ matrix.image_arch }} 65532:65532 2,3,4 51 51 51 1 + EXPECTED: ${{ matrix.image_arch }} 65532:65532 2,3,4 51 52 52 1 run: | set -euo pipefail actual="$(docker image inspect --format '{{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.local.application-config"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-min"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-max"}} {{index .Config.Labels "io.qinglong.local.sqlite-write-contract"}} {{index .Config.Labels "io.qinglong.local.compose-selection"}}' "${IMAGE}")" diff --git a/deploy/containers/ql3-local-application/Dockerfile b/deploy/containers/ql3-local-application/Dockerfile index b44a9d72..adc4941d 100644 --- a/deploy/containers/ql3-local-application/Dockerfile +++ b/deploy/containers/ql3-local-application/Dockerfile @@ -144,8 +144,8 @@ LABEL org.opencontainers.image.title="QingLong 3.0 Local Application" \ io.qinglong.ai="excluded" \ io.qinglong.local.application-config="2,3,4" \ io.qinglong.local.sqlite-contract-min="51" \ - io.qinglong.local.sqlite-contract-max="51" \ - io.qinglong.local.sqlite-write-contract="51" \ + io.qinglong.local.sqlite-contract-max="52" \ + io.qinglong.local.sqlite-write-contract="52" \ io.qinglong.local.compose-selection="1" ENV NODE_ENV=production diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 5e4ef54a..e82e7f7f 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -6,10 +6,10 @@ - 目标版本:QingLong 3.x - 作者:QingLong Maintainers - 创建日期:2026-07-17 -- 最后更新:2026-08-23 +- 最后更新:2026-08-24 - 讨论范围:架构与演进路线,不包含最终 UI 视觉方案 -最新增量证据(2026-08-23): +最新增量证据(2026-08-24): - D-397/ADR-0491(进行中):Secret/Config reconciliation 已先冻结行为保持边界,不能把“密文已保存”冒充“任务已迁移”。2.x `Envs` 的 active 行必须按 `isPinned DESC、position DESC、createdAt ASC、id ASC` 重放旧顺序,同名值用 `&` 形成唯一 effective Secret;该 @@ -37,18 +37,24 @@ digest。Secret/Config planner 不再扫描 `legacy-cron:*` 或相信聚合计数,而是流式复算 provenance,并验证当前 Task/Trigger head、Trigger schedule 与非 Plugin ownership。旧记录缺少逐项 provenance 为 `missing`,current revision/schedule/ownership 漂移为 `drifted`,两者都强制 `manual_required`;不得猜测或自动回填。聚合 adoption 记录仍只保留 Edge/Standalone 128/512 条有界 Map,Task/Trigger 逐行读取,无目标规模 Set。 - 全部 evidence 不含原 Env name/value、目标 ciphertext/key ID 或 row body。Local SQLite、Local Admin 与 Local Owner 完整测试分别为 `241/241`、 - `96/96` 与 `296 total / 289 pass / 7 conditional skip / 0 fail`;18-package clean build/test 在非沙箱环境全部通过。后端完整门在受限沙箱中为 - `1544 total / 1531 pass / 11 loopback EPERM / 2 conditional skip`,四个受影响文件随后在非沙箱环境 `38/38` 通过,未发现代码失败。package - boundary、72 项 Cluster dependency/legacy boundary、Edge import、本地镜像与十四档 Local artifact audit 全部 compatible;基础 Edge/Standalone 为 - `2,620,531 / 2,620,609 bytes`、321 files、58 loaded modules,Owner-only authority 没有进入低资源常驻制品。fresh Edge readiness 为 contract v51、 - 102 migrations、85 tables、SQLite 3.53.3、`DELETE` journal;PostgreSQL 18.6 arm64 physical HA 以 timeline `1 → 2`、146 gates 通过。 - Local Admin 保持 48/47,Local Owner 因六个职责明确的 decision 嵌套文件增至 184/183,根目录仍只有一个 50 行 binary entry;workspace 仍为 18 packages、 - `singleSourcePackages=[]`、`shallowSourcePackages=[]`,且只允许 exact Secret/Config row planner 导入 inspection subpath。 + 第六切片把原子 DML 下沉到既有 `@qinglong/local-sqlite/secret-config-application` 私有短生命周期 authority,不新增 package 或 Cluster 依赖。contract v52 + 增加 application/secret/task/trigger 四类 ledger;publisher 在一个 `BEGIN IMMEDIATE` 内复验 Project/RoleBinding、两次外部 authority、逐项 provenance、当前 + Task/Trigger head、Plugin ownership 与每 Task Trigger 数量,然后同时写入加密 Secret、content-free audit、Task rev2、local dispatch、Trigger rev2、schedule + 和 receipt。item 使用 deferred parent FK 流式落库,最终 receipt 才关闭父引用,因此最多 100,000 Task/500,000 Trigger 不进入 JS 全集或 O(N×M) 扫描。 + commit response-loss exact replay 会重验 Secret envelope、Task/Trigger durable head 与 schedule;Secret 占用、provenance 缺项和提交前 authority 漂移均回滚全部 DML。 + 全部 evidence 不含原 Env name/value、目标 ciphertext/key ID 或 row body。v52 Local SQLite 完整测试为 `247/247`,publisher 定向回归 `6/6`;fresh Edge + readiness 为 contract v52、104 migrations、89 required tables、SQLite 3.53.3、`DELETE` journal。Local Owner 为 + `296 total / 289 pass / 7 conditional skip / 0 fail`;18-package clean build 与逐包顺序测试单次退出 0,完整 backend 为 + `1566 total / 1564 pass / 2 conditional skip / 0 fail`。package boundary、Cluster dependency/legacy boundary、122-module Edge import、本地镜像与 + `14/14` Local artifact audit 全部 compatible;基础 Edge/Standalone 为 `2,635,529 / 2,635,607 bytes`、323 files、58 loaded modules,距 4 MiB + 上限仍分别保留 `1,558,775 / 1,558,697 bytes`,且闭包只有 Local SQLite、runtime-core 与 SemVer,没有 Cluster/PostgreSQL 依赖。 + Local SQLite 为 209 source / 208 nested / 1 root public export;Local Owner 保持 184/183,根目录仍只有一个 50 行 binary entry;workspace 仍为 + 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。本切片不改 PostgreSQL schema、连接、role、Pool、容器或 Kubernetes 拓扑, + 因而不重跑且不重新占有 PostgreSQL HA 证明;相邻已通过的 remote CI/HA 只作为基线。 D-385~D-388 的 `config.sh`/Keyv/SSH data-directory lineage 与 SQLite `Envs` 保持分离;当前无稳定生产 schema 的历史 `Configs` 表继续 sealed+manual, - 不猜字段。后续切片必须完成 Secret envelope + audit + Task/Trigger/dispatch + receipt ledger 的单事务发布、prepared/apply/rollback - lineage、completion 下一 schema 与备份回收。D-397 apply 只声明 sealed source retained 且 `physicalErasureGuaranteed=false`;明文销毁必须在 restart/ + 不猜字段。后续切片必须完成 Owner prepared/apply/rollback、写前 backup、receipt/head/seal response-loss、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 601caa57..60568249 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 与逐项 Automation adoption provenance;原子 application 尚未完成) +- 状态:Proposed(D-397 已实现 Legacy Env inspection、私有有界 row plan、durable plan publication、独立 signed decision、逐项 Automation adoption provenance 与 Local SQLite 原子 application publisher;Owner prepared/apply/rollback 编排尚未完成) - 日期: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 @@ -137,8 +137,8 @@ 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、逐候选独立 signed decision、同一强认证 reviewer、decision/authorization byte bound、`skip → manual_required`、prepare/commit response-loss exact replay、只读 terminal verify,以及 v51 逐 Task/Trigger adoption provenance。新 provenance 与 Automation Task/Trigger/audit/ledger 在同一事务发布并在 replay 时完整流式复算;Secret/Config plan 对 complete、missing、current-revision drift 和 Plugin ownership 失败关闭。 +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、v51 逐 Task/Trigger adoption provenance,以及 v52 Local SQLite 原子 application publisher。v52 在一个 `BEGIN IMMEDIATE` 内复验 Project/RoleBinding、外部 authority、逐 Task/Trigger provenance、当前 head、Plugin ownership 与 Trigger 数量,流式写入加密 Secret、content-free audit、Task rev2、dispatch、Trigger rev2、schedule 和四类 application ledger;deferred parent FK 允许最多 100,000 Task/500,000 Trigger 逐项发布而不在 JS 堆保留全集。commit response-loss 通过 durable receipt exact replay,并重新验证 Secret envelope、Task/Trigger head 与 schedule;目标占用、provenance 缺项、提交前 authority 漂移均回滚全部 DML。 -本切片验证已闭合:Local SQLite `241/241`、Local Admin `96/96`、Local Owner `296 total / 289 pass / 7 conditional skip / 0 fail`,Secret/Config 定向回归 `51/51`;18-package clean build/test 在非沙箱环境全部通过。后端完整门在受限沙箱中仅有 11 个 loopback `listen EPERM`,四个受影响文件在非沙箱环境 `38/38` 通过。package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,72 项 Cluster dependency/legacy boundary、Edge import、本地镜像与十四档 artifact audit 全 compatible;基础 Edge/Standalone 为 `2,620,531 / 2,620,609 bytes`、321 files、58 modules。fresh Edge readiness 为 contract v51、102 migrations、85 tables、SQLite 3.53.3、`DELETE` journal。PostgreSQL 18.6 arm64 physical HA 以 146 gates、timeline `1 → 2` 通过;两次前置 `initdb` 超时均由本机 Docker 虚拟磁盘 100% 占满导致,释放可重建缓存后同一门成功。 +本切片当前验证:Local SQLite `247/247`,其中 Secret/Config application publisher 定向回归 `6/6`;fresh Edge readiness 为 contract v52、104 migrations、89 required tables、SQLite 3.53.3、`DELETE` journal。Local Owner 为 `296 total / 289 pass / 7 conditional skip / 0 fail`;18-package clean build 与逐包顺序测试单次退出 0,完整 backend 为 `1566 total / 1564 pass / 2 conditional skip / 0 fail`。package boundary、Cluster dependency/legacy boundary、122-module Edge import、本地镜像与 `14/14` Local artifact audit 全部 compatible;基础 Edge/Standalone 为 `2,635,529 / 2,635,607 bytes`、323 files、58 loaded modules,且没有 Cluster/PostgreSQL 闭包。本切片不改 PostgreSQL schema、连接、role、Pool、容器或 Kubernetes 拓扑,因此不重跑且不重新占有 PostgreSQL HA 证明。 -转为 Accepted 前仍必须完成:原子 Secret/Task/Trigger/dispatch publisher、prepared/apply/rollback response-loss、completion schema 演进、在后续原子 application 切片上重新执行 18-package/boundary/artifact gates、真实 Edge 空间预算与 Cluster Secret provider live gate;本切片的 PostgreSQL HA 证据不得冒充后续 Cluster 密钥托管证明。 +转为 Accepted 前仍必须完成:Owner prepared/apply/rollback 与写前 backup、application receipt/head/seal response-loss、completion schema 演进、真实 Edge 空间预算与 Cluster Secret provider live gate;本切片的 Local SQLite publisher 或 PostgreSQL HA 证据不得冒充 Owner 编排、外部密钥托管或跨领域 completion 已完成。 diff --git a/docs/ql3-package-boundaries.json b/docs/ql3-package-boundaries.json index fa60f34f..c3e39d84 100644 --- a/docs/ql3-package-boundaries.json +++ b/docs/ql3-package-boundaries.json @@ -12,7 +12,7 @@ }, { "kind": "ordered_ledger", - "maxDirectSourceFiles": 103, + "maxDirectSourceFiles": 105, "path": "packages/ql3-local-sqlite/src/migrations", "rationale": "SQLite migrations are an append-only version ledger whose ordering and discoverability are safer in one reviewed directory." } diff --git a/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs b/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs index 29314ad9..e500162c 100644 --- a/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs +++ b/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs @@ -367,8 +367,8 @@ function adoptedDockerHarness(state, options = {}) { ], Labels: { 'io.qinglong.local.sqlite-contract-min': '51', - 'io.qinglong.local.sqlite-contract-max': '51', - 'io.qinglong.local.sqlite-write-contract': '51', + 'io.qinglong.local.sqlite-contract-max': '52', + 'io.qinglong.local.sqlite-write-contract': '52', 'io.qinglong.local.application-config': '2,3,4', 'io.qinglong.local.compose-selection': '1', 'io.qinglong.ai': 'excluded', @@ -839,7 +839,7 @@ test('preflights adopted Compose identity mounts and rejects mount drift', async ); assert.equal(ready.status, 'ready'); assert.equal(ready.profile, 'edge'); - assert.equal(ready.sqlite.contractVersion, 51); + assert.equal(ready.sqlite.contractVersion, 52); await assert.rejects( preflightLocalDeploymentCompose(composePreflightCommand(state, 1), { runDocker: adoptedDockerHarness(state, { driftMount: true }).runDocker, diff --git a/packages/ql3-local-owner-cli/test/localDeployment.test.cjs b/packages/ql3-local-owner-cli/test/localDeployment.test.cjs index 7ecf530c..b3ced77a 100644 --- a/packages/ql3-local-owner-cli/test/localDeployment.test.cjs +++ b/packages/ql3-local-owner-cli/test/localDeployment.test.cjs @@ -491,8 +491,8 @@ function composeDockerHarness( ], Labels: { 'io.qinglong.local.sqlite-contract-min': '51', - 'io.qinglong.local.sqlite-contract-max': '51', - 'io.qinglong.local.sqlite-write-contract': '51', + 'io.qinglong.local.sqlite-contract-max': '52', + 'io.qinglong.local.sqlite-write-contract': '52', 'io.qinglong.local.application-config': '2', 'io.qinglong.local.compose-selection': '1', 'io.qinglong.ai': 'excluded', @@ -1200,8 +1200,8 @@ test('preflights exact local image, Compose merge and SQLite capability', async ], Labels: { 'io.qinglong.local.sqlite-contract-min': '51', - 'io.qinglong.local.sqlite-contract-max': '51', - 'io.qinglong.local.sqlite-write-contract': '51', + 'io.qinglong.local.sqlite-contract-max': '52', + 'io.qinglong.local.sqlite-write-contract': '52', 'io.qinglong.local.application-config': '2', 'io.qinglong.local.compose-selection': '1', 'io.qinglong.ai': 'excluded', @@ -1259,7 +1259,7 @@ test('preflights exact local image, Compose merge and SQLite capability', async assert.equal(result.status, 'ready'); assert.equal(result.generation, 1); assert.equal(result.profile, 'edge'); - assert.equal(result.sqlite.contractVersion, 51); + assert.equal(result.sqlite.contractVersion, 52); assert.equal(result.image.architecture, 'arm64'); assert.equal(calls.length, 2); assert.deepEqual(calls[0].slice(0, 2), ['image', 'inspect']); @@ -1359,8 +1359,8 @@ test('applies one Compose generation and exactly replays its health receipt', as assert.equal(mode(receiptPath), 0o600); const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8')); assert.deepEqual(receipt.sqlite, { - contractVersion: 51, - writeContractVersion: 51, + contractVersion: 52, + writeContractVersion: 52, writeObservation: 'unchanged', backup: null, }); @@ -1661,8 +1661,8 @@ test('rolls a failed Compose candidate forward to a healthy prior digest', async `${command.request.rolloutId}.sqlite`, ); assert.equal(mode(backupPath), 0o600); - assert.equal(receipt.sqlite.contractVersion, 51); - assert.equal(receipt.sqlite.writeContractVersion, 51); + assert.equal(receipt.sqlite.contractVersion, 52); + assert.equal(receipt.sqlite.writeContractVersion, 52); assert.equal(receipt.sqlite.writeObservation, 'changed'); assert.match(receipt.sqlite.backup.sha256, /^[0-9a-f]{64}$/); assert.equal(receipt.sqlite.backup.bytes > 0, true); diff --git a/packages/ql3-local-owner-cli/test/localReadiness.test.cjs b/packages/ql3-local-owner-cli/test/localReadiness.test.cjs index 2029a10c..634b4704 100644 --- a/packages/ql3-local-owner-cli/test/localReadiness.test.cjs +++ b/packages/ql3-local-owner-cli/test/localReadiness.test.cjs @@ -34,8 +34,8 @@ test('inspects the exact fresh Profile schema without exposing its path', async assert.equal(result.status, 'ready'); assert.equal(result.profile, 'edge'); assert.equal(result.storage.contractName, 'local-control-core'); - assert.equal(result.storage.contractVersion, 51); - assert.equal(result.storage.migrationCount, 102); + assert.equal(result.storage.contractVersion, 52); + assert.equal(result.storage.migrationCount, 104); assert.equal(result.storage.journalMode, 'delete'); assert.equal(JSON.stringify(result).includes(state.directory), false); }); diff --git a/packages/ql3-local-sqlite/package.json b/packages/ql3-local-sqlite/package.json index 55204a51..d33834da 100644 --- a/packages/ql3-local-sqlite/package.json +++ b/packages/ql3-local-sqlite/package.json @@ -90,6 +90,11 @@ "require": "./dist/adoption/data-directory/applicationCommit.js", "default": "./dist/adoption/data-directory/applicationCommit.js" }, + "./secret-config-application": { + "types": "./dist/adoption/secret-config/secretConfigApplicationDatabase.d.ts", + "require": "./dist/adoption/secret-config/secretConfigApplicationDatabase.js", + "default": "./dist/adoption/secret-config/secretConfigApplicationDatabase.js" + }, "./plugin-package-install": { "types": "./dist/plugin-package/pluginPackageInstallRepository.d.ts", "require": "./dist/plugin-package/pluginPackageInstallRepository.js", diff --git a/packages/ql3-local-sqlite/src/adoption/secret-config/secretConfigApplicationDatabase.ts b/packages/ql3-local-sqlite/src/adoption/secret-config/secretConfigApplicationDatabase.ts new file mode 100644 index 00000000..e0371292 --- /dev/null +++ b/packages/ql3-local-sqlite/src/adoption/secret-config/secretConfigApplicationDatabase.ts @@ -0,0 +1,1048 @@ +import { createHash } from 'node:crypto'; +import type { DatabaseSync } from 'node:sqlite'; + +import { + createLocalSecretRef, + normalizeLocalSecretEnvelope, + type LocalSecretEnvelope, +} from '@qinglong/runtime-core/local-secret'; +import { + normalizeProjectPolicySubject, + type ProjectPolicyRepository, +} from '@qinglong/runtime-core/project-policy'; +import type { + SecurityPolicyFence, + SecuritySubject, +} from '@qinglong/runtime-core/security'; +import { + normalizeSecurityAuditRecord, + type SecurityAuditRecord, + type SecurityAuditSink, +} from '@qinglong/runtime-core/security-audit'; +import { + createTaskDefinitionRecord, + normalizeAppendTaskDefinitionRevisionCommand, + normalizeTaskDefinitionRecord, + type TaskDefinitionJson, + type TaskDefinitionRecord, +} from '@qinglong/runtime-core/task-definition'; +import { + BUILT_IN_COMMAND_TASK_SPEC_SCHEMA, + createBuiltInTaskSpecSemanticRegistry, +} from '@qinglong/runtime-core/task-spec-semantic'; +import { compileLocalCommandTaskDefinition } from '@qinglong/runtime-core/task-definition-execution-compiler'; +import { + createBuiltInTriggerSpecSemanticRegistry, + createTriggerRecord, + normalizeAppendTriggerRevisionCommand, + normalizeTriggerRecord, + type TriggerRecord, +} from '@qinglong/runtime-core/trigger'; + +import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority'; +import { LocalSqliteDispatchDefinitionStore } from '../../task-definition/dispatchDefinitionStore'; +import { + auditLocalSqliteReadiness, + type LocalSqliteReadinessEvidence, +} from '../../readiness/readiness'; +import { LocalSqliteSecurityAuthorityStore } from '../../security/securityAuthorityStore'; +import { + assertLocalSqliteOptions, + assertLocalSqlitePathBoundary, + openLocalSqliteClient, + type LocalSqliteDatabaseOptions, + type LocalSqliteProfile, +} from '../../storage/config'; + +const DIGEST = /^[0-9a-f]{64}$/; +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; +type Row = Record; + +export interface LocalSecretConfigApplicationSecret { + readonly ordinal: number; + readonly disposition: 'active_binding' | 'disabled_preservation'; + readonly candidateDigest: string; + readonly sourceSetDigest: string; + readonly environmentName?: string; + readonly envelope: Readonly; + readonly audit: Readonly; +} + +export interface PublishLocalSecretConfigApplicationCommand { + readonly mutationId: string; + readonly projectId: string; + readonly profile: LocalSqliteProfile; + readonly secretConfigPlanDigest: string; + readonly decisionDigest: string; + readonly candidateSetDigest: string; + readonly automationAdoptionSetDigest: string; + readonly subject: Readonly; + readonly fence: Readonly; + readonly audit: Readonly; + readonly secrets: readonly Readonly[]; + readonly appliedAtMs: number; + readonly confirmExternalAuthority: () => void | Promise; +} + +export interface LocalSecretConfigApplicationReceipt { + readonly schema: 'qinglong/local-secret-config-application-receipt@v1'; + readonly mutationId: string; + readonly projectId: string; + readonly profile: LocalSqliteProfile; + readonly secretConfigPlanDigest: string; + readonly decisionDigest: string; + readonly candidateSetDigest: string; + readonly automationAdoptionSetDigest: string; + readonly activeBindingCount: number; + readonly disabledPreservationCount: number; + readonly taskCount: number; + readonly triggerCount: number; + readonly publicationDigest: string; + readonly auditEventId: string; + readonly appliedAtMs: number; + readonly physicalErasureGuaranteed: false; + readonly receiptDigest: string; +} + +export interface LocalSecretConfigApplicationRecord { + readonly receipt: Readonly; +} + +export class LocalSecretConfigApplicationConflictError extends Error { + readonly code = 'LOCAL_SECRET_CONFIG_APPLICATION_CONFLICT'; + constructor() { + super('Local Secret/Config application conflicts with durable state'); + this.name = 'LocalSecretConfigApplicationConflictError'; + } +} + +export class LocalSecretConfigApplicationAuthorizationFenceConflictError extends Error { + readonly code = + 'LOCAL_SECRET_CONFIG_APPLICATION_AUTHORIZATION_FENCE_CONFLICT'; + constructor() { + super('Local Secret/Config application authorization fence changed'); + this.name = 'LocalSecretConfigApplicationAuthorizationFenceConflictError'; + } +} + +export class LocalSecretConfigApplicationUnavailableError extends Error { + readonly code = 'LOCAL_SECRET_CONFIG_APPLICATION_UNAVAILABLE'; + constructor(readonly cause?: unknown) { + super('Local Secret/Config application storage is unavailable'); + this.name = 'LocalSecretConfigApplicationUnavailableError'; + } +} + +function text(row: Row, key: string): string { + const value = row[key]; + if (typeof value !== 'string') + throw new LocalSecretConfigApplicationUnavailableError(); + return value; +} + +function integer(row: Row, key: string): number { + const value = row[key]; + if (!Number.isSafeInteger(value)) + throw new LocalSecretConfigApplicationUnavailableError(); + return value as number; +} + +function json(row: Row, key: string): unknown { + try { + return JSON.parse(text(row, key)); + } catch (error) { + throw new LocalSecretConfigApplicationUnavailableError(error); + } +} + +function sha256(domain: string, value: unknown): string { + return createHash('sha256') + .update(domain) + .update(JSON.stringify(value)) + .digest('hex'); +} + +function deterministicMutationId( + batch: string, + kind: string, + identity: string, +): string { + const bytes = createHash('sha256') + .update('qinglong3.secret-config-application-mutation.v1\0') + .update(batch) + .update('\0') + .update(kind) + .update('\0') + .update(identity) + .digest() + .subarray(0, 16); + bytes[6] = ((bytes[6] as number) & 0x0f) | 0x40; + bytes[8] = ((bytes[8] as number) & 0x3f) | 0x80; + const hex = bytes.toString('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice( + 12, + 16, + )}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function insertAudit( + client: DatabaseSync, + audit: Readonly, +): void { + client + .prepare( + `INSERT INTO "QingLong3SecurityAuditEvents" ( + "event_id", "request_id", "operation_id", "project_id", + "subject_type", "subject_id", "authentication_id", "outcome", + "reasons_json", "fence_project_version", "fence_binding_version", + "occurred_at_ms" + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + audit.eventId, + audit.requestId, + audit.operationId, + audit.projectId, + audit.subject?.type ?? null, + audit.subject?.id ?? null, + audit.authenticationId, + audit.outcome, + JSON.stringify(audit.reasons), + audit.fence?.projectVersion ?? null, + audit.fence?.bindingVersion ?? null, + audit.occurredAtMs, + ); +} + +function taskFromRow(row: Row): TaskDefinitionRecord { + return normalizeTaskDefinitionRecord({ + projectId: text(row, 'projectId'), + taskId: text(row, 'taskId'), + revision: integer(row, 'revision'), + mutationId: text(row, 'mutationId'), + name: text(row, 'name'), + ...(row.description === null + ? {} + : { description: text(row, 'description') }), + kind: text(row, 'kind') as TaskDefinitionRecord['kind'], + spec: json(row, 'specJson') as TaskDefinitionRecord['spec'], + labels: json(row, 'labelsJson') as TaskDefinitionRecord['labels'], + enabled: integer(row, 'enabled') === 1, + contentDigest: text(row, 'contentDigest'), + createdAtMs: integer(row, 'createdAtMs'), + updatedAtMs: integer(row, 'updatedAtMs'), + }); +} + +function triggerFromRow(row: Row): TriggerRecord { + return normalizeTriggerRecord({ + projectId: text(row, 'projectId'), + triggerId: text(row, 'triggerId'), + revision: integer(row, 'revision'), + mutationId: text(row, 'mutationId'), + taskId: text(row, 'taskId'), + taskRevision: integer(row, 'taskRevision'), + taskContentDigest: text(row, 'taskContentDigest'), + spec: json(row, 'specJson') as TriggerRecord['spec'], + enabled: integer(row, 'enabled') === 1, + contentDigest: text(row, 'contentDigest'), + createdAtMs: integer(row, 'createdAtMs'), + updatedAtMs: integer(row, 'updatedAtMs'), + }); +} + +function normalizeCommand( + command: Readonly, +) { + if ( + !command || + typeof command !== 'object' || + !UUID_V4.test(command.mutationId) || + (command.profile !== 'edge' && command.profile !== 'standalone') || + ![ + command.secretConfigPlanDigest, + command.decisionDigest, + command.candidateSetDigest, + command.automationAdoptionSetDigest, + ].every((value) => DIGEST.test(value)) || + !Array.isArray(command.secrets) || + command.secrets.length < 1 || + command.secrets.length > (command.profile === 'edge' ? 384 : 768) || + !Number.isSafeInteger(command.appliedAtMs) || + command.appliedAtMs < 0 || + typeof command.confirmExternalAuthority !== 'function' + ) + throw new LocalSecretConfigApplicationConflictError(); + const subject = normalizeProjectPolicySubject(command.subject); + const audit = normalizeSecurityAuditRecord(command.audit); + if ( + !command.fence || + !Number.isSafeInteger(command.fence.projectVersion) || + command.fence.projectVersion < 1 || + !Number.isSafeInteger(command.fence.bindingVersion) || + (command.fence.bindingVersion as number) < 1 || + audit.eventId !== command.mutationId || + audit.operationId !== 'secret-config.apply' || + audit.projectId !== command.projectId || + audit.subject?.type !== subject.type || + audit.subject.id !== subject.id || + audit.outcome !== 'allowed' || + audit.fence?.projectVersion !== command.fence.projectVersion || + audit.fence.bindingVersion !== command.fence.bindingVersion || + audit.occurredAtMs !== command.appliedAtMs + ) + throw new LocalSecretConfigApplicationConflictError(); + const names = new Set(); + const environmentNames = new Set(); + let activeBindingCount = 0; + let disabledPreservationCount = 0; + const secrets = command.secrets.map((entry, index) => { + const envelope = normalizeLocalSecretEnvelope(entry.envelope); + const itemAudit = normalizeSecurityAuditRecord(entry.audit); + const active = entry.disposition === 'active_binding'; + if ( + entry.ordinal !== index + 1 || + (!active && entry.disposition !== 'disabled_preservation') || + !DIGEST.test(entry.candidateDigest) || + !DIGEST.test(entry.sourceSetDigest) || + envelope.projectId !== command.projectId || + envelope.version !== 1 || + envelope.createdAtMs !== command.appliedAtMs || + !UUID_V4.test(envelope.mutationId) || + names.has(envelope.name) || + (active + ? typeof entry.environmentName !== 'string' || + !ENVIRONMENT_NAME.test(entry.environmentName) || + entry.environmentName.startsWith('QL3_') || + environmentNames.has(entry.environmentName) + : entry.environmentName !== undefined) || + itemAudit.eventId !== envelope.mutationId || + itemAudit.operationId !== 'secret.create' || + itemAudit.projectId !== command.projectId || + itemAudit.subject?.type !== subject.type || + itemAudit.subject.id !== subject.id || + itemAudit.outcome !== 'allowed' || + itemAudit.fence?.projectVersion !== command.fence.projectVersion || + itemAudit.fence.bindingVersion !== command.fence.bindingVersion || + itemAudit.occurredAtMs !== command.appliedAtMs + ) + throw new LocalSecretConfigApplicationConflictError(); + names.add(envelope.name); + if (active) { + activeBindingCount += 1; + environmentNames.add(entry.environmentName as string); + } else disabledPreservationCount += 1; + const secretRef = createLocalSecretRef({ + projectId: command.projectId, + name: envelope.name, + version: 1, + }); + const semantic = { + ordinal: entry.ordinal, + disposition: entry.disposition, + candidateDigest: entry.candidateDigest, + sourceSetDigest: entry.sourceSetDigest, + ...(active ? { environmentName: entry.environmentName } : {}), + secretName: envelope.name, + secretVersion: 1, + secretMutationId: envelope.mutationId, + secretRef, + }; + return Object.freeze({ + entry, + envelope, + audit: itemAudit, + secretRef, + itemDigest: sha256( + 'qinglong3.secret-config-application-secret.v1\0', + semantic, + ), + }); + }); + if ( + activeBindingCount > 256 || + disabledPreservationCount > (command.profile === 'edge' ? 128 : 512) + ) { + throw new LocalSecretConfigApplicationConflictError(); + } + return Object.freeze({ + subject, + audit, + secrets: Object.freeze(secrets), + activeBindingCount, + disabledPreservationCount, + }); +} + +function findApplication( + client: DatabaseSync, + mutationId: string, +): LocalSecretConfigApplicationRecord | null { + const row = client + .prepare( + `SELECT "receipt_json" AS "receiptJson" FROM "QingLong3SecretConfigApplications" WHERE "mutation_id" = ?`, + ) + .get(mutationId) as Row | undefined; + if (!row) return null; + const receipt = json( + row, + 'receiptJson', + ) as LocalSecretConfigApplicationReceipt; + if ( + !receipt || + typeof receipt !== 'object' || + !DIGEST.test(receipt.receiptDigest) + ) { + throw new LocalSecretConfigApplicationUnavailableError(); + } + const { receiptDigest, ...payload } = receipt; + if ( + sha256('qinglong3.secret-config-application-receipt.v1\0', payload) !== + receiptDigest + ) { + throw new LocalSecretConfigApplicationUnavailableError(); + } + return Object.freeze({ receipt: Object.freeze(receipt) }); +} + +function sameReplay( + record: Readonly, + command: Readonly, + counts: { activeBindingCount: number; disabledPreservationCount: number }, +): boolean { + const receipt = record.receipt; + return ( + receipt.mutationId === command.mutationId && + receipt.projectId === command.projectId && + receipt.profile === command.profile && + receipt.secretConfigPlanDigest === command.secretConfigPlanDigest && + receipt.decisionDigest === command.decisionDigest && + receipt.candidateSetDigest === command.candidateSetDigest && + receipt.automationAdoptionSetDigest === + command.automationAdoptionSetDigest && + receipt.activeBindingCount === counts.activeBindingCount && + receipt.disabledPreservationCount === counts.disabledPreservationCount && + receipt.appliedAtMs === command.appliedAtMs + ); +} + +const TASK_SELECT = ` + head."project_id" AS "projectId", head."task_id" AS "taskId", + revision."revision" AS "revision", revision."mutation_id" AS "mutationId", + revision."name" AS "name", revision."description" AS "description", + revision."kind" AS "kind", revision."spec_json" AS "specJson", + revision."labels_json" AS "labelsJson", revision."enabled" AS "enabled", + revision."content_digest" AS "contentDigest", + head."created_at_ms" AS "createdAtMs", revision."created_at_ms" AS "updatedAtMs"`; + +const TRIGGER_SELECT = ` + head."project_id" AS "projectId", head."trigger_id" AS "triggerId", + revision."revision" AS "revision", revision."mutation_id" AS "mutationId", + revision."task_id" AS "taskId", revision."task_revision" AS "taskRevision", + revision."task_content_digest" AS "taskContentDigest", + revision."spec_json" AS "specJson", revision."enabled" AS "enabled", + revision."content_digest" AS "contentDigest", + head."created_at_ms" AS "createdAtMs", revision."created_at_ms" AS "updatedAtMs"`; + +export class LocalSqliteSecretConfigApplicationPublisher { + constructor(private readonly authority: LocalSqliteOperationAuthority) {} + + resolve( + mutationId: string, + ): Promise | null> { + if (!UUID_V4.test(mutationId)) + return Promise.reject(new LocalSecretConfigApplicationConflictError()); + return this.authority.enqueue( + async () => findApplication(this.authority.client, mutationId), + () => new LocalSecretConfigApplicationUnavailableError(), + ); + } + + publish( + command: Readonly, + ): Promise< + Readonly<{ + status: 'inserted' | 'existing'; + application: LocalSecretConfigApplicationRecord; + }> + > { + const normalized = normalizeCommand(command); + return this.authority.enqueue( + async () => { + const client = this.authority.client; + let began = false; + try { + client.exec('BEGIN IMMEDIATE'); + began = true; + await command.confirmExternalAuthority(); + const replay = findApplication(client, command.mutationId); + if (replay) { + if (!sameReplay(replay, command, normalized)) + throw new LocalSecretConfigApplicationConflictError(); + const secretRows = client + .prepare( + `SELECT item."secret_name" AS "secretName", item."secret_mutation_id" AS "secretMutationId", item."item_digest" AS "itemDigest", secret."key_id" AS "keyId", secret."nonce" AS "nonce", secret."ciphertext" AS "ciphertext", secret."auth_tag" AS "authTag" FROM "QingLong3SecretConfigApplicationSecrets" AS item JOIN "QingLong3LocalSecretEnvelopes" AS secret ON secret."project_id" = item."project_id" AND secret."secret_name" = item."secret_name" AND secret."version" = item."secret_version" WHERE item."application_mutation_id" = ? ORDER BY item."ordinal"`, + ) + .all(command.mutationId) as Row[]; + if ( + secretRows.length !== normalized.secrets.length || + secretRows.some((row, index) => { + const expected = normalized.secrets[index]!; + return ( + text(row, 'secretName') !== expected.envelope.name || + text(row, 'secretMutationId') !== + expected.envelope.mutationId || + text(row, 'itemDigest') !== expected.itemDigest || + text(row, 'keyId') !== expected.envelope.keyId || + !(row.nonce instanceof Uint8Array) || + Buffer.from(row.nonce).toString('base64url') !== + expected.envelope.nonce || + !(row.ciphertext instanceof Uint8Array) || + Buffer.from(row.ciphertext).toString('base64url') !== + expected.envelope.ciphertext || + !(row.authTag instanceof Uint8Array) || + Buffer.from(row.authTag).toString('base64url') !== + expected.envelope.authTag + ); + }) + ) + throw new LocalSecretConfigApplicationConflictError(); + const taskCount = integer( + client + .prepare( + `SELECT count(*) AS "count" FROM "QingLong3SecretConfigApplicationTasks" AS item JOIN "QingLong3TaskDefinitions" AS head ON head."project_id" = item."project_id" AND head."task_id" = item."task_id" JOIN "QingLong3TaskDefinitionRevisions" AS revision ON revision."project_id" = item."project_id" AND revision."task_id" = item."task_id" AND revision."revision" = item."task_revision" WHERE item."application_mutation_id" = ? AND head."current_revision" = item."task_revision" AND revision."mutation_id" = item."task_mutation_id" AND revision."content_digest" = item."task_content_digest"`, + ) + .get(command.mutationId) as Row, + 'count', + ); + const triggerCount = integer( + client + .prepare( + `SELECT count(*) AS "count" FROM "QingLong3SecretConfigApplicationTriggers" AS item JOIN "QingLong3Triggers" AS head ON head."project_id" = item."project_id" AND head."trigger_id" = item."trigger_id" JOIN "QingLong3TriggerRevisions" AS revision ON revision."project_id" = item."project_id" AND revision."trigger_id" = item."trigger_id" AND revision."revision" = item."trigger_revision" JOIN "QingLong3LocalTriggerSchedules" AS schedule ON schedule."project_id" = item."project_id" AND schedule."trigger_id" = item."trigger_id" AND schedule."trigger_revision" = item."trigger_revision" WHERE item."application_mutation_id" = ? AND head."current_revision" = item."trigger_revision" AND revision."mutation_id" = item."trigger_mutation_id" AND revision."content_digest" = item."trigger_content_digest"`, + ) + .get(command.mutationId) as Row, + 'count', + ); + if ( + taskCount !== replay.receipt.taskCount || + triggerCount !== replay.receipt.triggerCount + ) + throw new LocalSecretConfigApplicationConflictError(); + await command.confirmExternalAuthority(); + client.exec('COMMIT'); + began = false; + return Object.freeze({ + status: 'existing' as const, + application: replay, + }); + } + + const project = client + .prepare( + `SELECT "version", "status" FROM "QingLong3Projects" WHERE "id" = ?`, + ) + .get(command.projectId) as Row | undefined; + const binding = client + .prepare( + `SELECT "version", "state", "role" FROM "QingLong3ProjectRoleBindings" WHERE "project_id" = ? AND "subject_type" = ? AND "subject_id" = ? ORDER BY "version" DESC LIMIT 1`, + ) + .get( + command.projectId, + normalized.subject.type, + normalized.subject.id, + ) as Row | undefined; + if ( + !project || + integer(project, 'version') !== command.fence.projectVersion || + text(project, 'status') !== 'active' || + !binding || + integer(binding, 'version') !== command.fence.bindingVersion || + text(binding, 'state') !== 'active' || + !['owner', 'admin'].includes(text(binding, 'role')) + ) + throw new LocalSecretConfigApplicationAuthorizationFenceConflictError(); + + for (const secret of normalized.secrets) { + const occupied = client + .prepare( + `SELECT 1 FROM "QingLong3LocalSecretEnvelopes" WHERE "project_id" = ? AND "secret_name" = ? LIMIT 1`, + ) + .get(command.projectId, secret.envelope.name); + if (occupied) throw new LocalSecretConfigApplicationConflictError(); + const nonce = Buffer.from(secret.envelope.nonce, 'base64url'); + const ciphertext = Buffer.from( + secret.envelope.ciphertext, + 'base64url', + ); + const authTag = Buffer.from(secret.envelope.authTag, 'base64url'); + try { + client + .prepare( + `INSERT INTO "QingLong3LocalSecretEnvelopes" ("project_id", "secret_name", "version", "mutation_id", "key_id", "algorithm", "nonce", "ciphertext", "auth_tag", "created_at_ms") VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + command.projectId, + secret.envelope.name, + secret.envelope.mutationId, + secret.envelope.keyId, + secret.envelope.algorithm, + nonce, + ciphertext, + authTag, + secret.envelope.createdAtMs, + ); + } finally { + nonce.fill(0); + ciphertext.fill(0); + authTag.fill(0); + } + insertAudit(client, secret.audit); + client + .prepare( + `INSERT INTO "QingLong3SecretConfigApplicationSecrets" ("application_mutation_id", "ordinal", "project_id", "disposition", "candidate_digest", "source_set_digest", "environment_name", "secret_name", "secret_version", "secret_mutation_id", "secret_ref", "item_digest") VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, + ) + .run( + command.mutationId, + secret.entry.ordinal, + command.projectId, + secret.entry.disposition, + secret.entry.candidateDigest, + secret.entry.sourceSetDigest, + secret.entry.environmentName ?? null, + secret.envelope.name, + secret.envelope.mutationId, + secret.secretRef, + secret.itemDigest, + ); + } + + const activeEnvironment = normalized.secrets + .filter(({ entry }) => entry.disposition === 'active_binding') + .map(({ entry, secretRef }) => + Object.freeze({ + name: entry.environmentName as string, + kind: 'secret' as const, + secretRef, + }), + ); + const taskRegistry = createBuiltInTaskSpecSemanticRegistry(); + const dispatch = new LocalSqliteDispatchDefinitionStore(client); + const publicationHash = createHash('sha256') + .update('qinglong3.secret-config-application-publication.v1\0') + .update(command.mutationId); + for (const item of normalized.secrets) + publicationHash.update('\0').update(item.itemDigest); + let taskCount = 0; + if (activeEnvironment.length > 0) { + const provenance = client + .prepare( + `SELECT p."adoption_mutation_id" AS "adoptionMutationId", p."row_ordinal" AS "rowOrdinal", p."task_id" AS "taskId", p."task_revision" AS "taskRevision", p."task_mutation_id" AS "taskMutationId", p."task_content_digest" AS "taskContentDigest", p."trigger_count" AS "triggerCount" FROM "QingLong3LegacyAdoptionTasks" AS p JOIN "QingLong3LegacyAdoptions" AS a ON a."mutation_id" = p."adoption_mutation_id" AND a."project_id" = p."project_id" WHERE p."project_id" = ? ORDER BY a."created_at_ms", p."adoption_mutation_id", p."row_ordinal"`, + ) + .iterate(command.projectId) as Iterable; + let ordinal = 0; + for (const proof of provenance) { + ordinal += 1; + const taskId = text(proof, 'taskId'); + const ownership = client + .prepare( + `SELECT 1 FROM "QingLong3PluginPackageTaskOwnerships" WHERE "project_id" = ? AND "task_id" = ?`, + ) + .get(command.projectId, taskId); + const row = client + .prepare( + `SELECT ${TASK_SELECT} FROM "QingLong3TaskDefinitions" AS head JOIN "QingLong3TaskDefinitionRevisions" AS revision ON revision."project_id" = head."project_id" AND revision."task_id" = head."task_id" AND revision."revision" = head."current_revision" WHERE head."project_id" = ? AND head."task_id" = ?`, + ) + .get(command.projectId, taskId) as Row | undefined; + if (!row || ownership) + throw new LocalSecretConfigApplicationConflictError(); + const previous = taskFromRow(row); + if ( + previous.revision !== 1 || + previous.mutationId !== text(proof, 'taskMutationId') || + previous.contentDigest !== text(proof, 'taskContentDigest') || + previous.kind !== 'command' || + previous.spec.schema !== BUILT_IN_COMMAND_TASK_SPEC_SCHEMA + ) + throw new LocalSecretConfigApplicationConflictError(); + const config = previous.spec.config as Readonly< + Record + >; + const existingEnvironment = Array.isArray(config.environment) + ? config.environment + : []; + const existingNames = new Set( + existingEnvironment.map( + (entry) => (entry as { name?: unknown }).name, + ), + ); + if (activeEnvironment.some(({ name }) => existingNames.has(name))) + throw new LocalSecretConfigApplicationConflictError(); + const spec = { + schema: previous.spec.schema, + config: { + ...config, + environment: [...existingEnvironment, ...activeEnvironment], + }, + }; + const base = normalizeAppendTaskDefinitionRevisionCommand({ + projectId: command.projectId, + taskId, + expectedRevision: 1, + mutationId: deterministicMutationId( + command.mutationId, + 'task', + taskId, + ), + name: previous.name, + ...(previous.description === undefined + ? {} + : { description: previous.description }), + kind: previous.kind, + spec, + labels: previous.labels, + enabled: previous.enabled, + occurredAtMs: command.appliedAtMs, + }); + const normalizedSpec = taskRegistry.normalize({ + projectId: base.projectId, + taskId: base.taskId, + kind: base.kind, + spec: base.spec, + }); + const current = createTaskDefinitionRecord( + { ...base, spec: normalizedSpec }, + previous.createdAtMs, + ); + client + .prepare( + `INSERT INTO "QingLong3TaskDefinitionRevisions" ("project_id", "task_id", "revision", "mutation_id", "name", "description", "kind", "spec_json", "labels_json", "enabled", "content_digest", "created_at_ms") VALUES (?, ?, 2, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + current.projectId, + current.taskId, + current.mutationId, + current.name, + current.description ?? null, + current.kind, + JSON.stringify(current.spec), + JSON.stringify(current.labels), + current.enabled ? 1 : 0, + current.contentDigest, + current.updatedAtMs, + ); + if (current.enabled) + dispatch.appendPlan( + compileLocalCommandTaskDefinition(current, taskRegistry), + ); + const updated = client + .prepare( + `UPDATE "QingLong3TaskDefinitions" SET "current_revision" = 2, "updated_at_ms" = ? WHERE "project_id" = ? AND "task_id" = ? AND "current_revision" = 1`, + ) + .run(current.updatedAtMs, current.projectId, current.taskId); + if (updated.changes !== 1) + throw new LocalSecretConfigApplicationConflictError(); + const itemDigest = sha256( + 'qinglong3.secret-config-application-task.v1\0', + { + ordinal, + adoptionMutationId: text(proof, 'adoptionMutationId'), + rowOrdinal: integer(proof, 'rowOrdinal'), + taskId, + previousContentDigest: previous.contentDigest, + taskMutationId: current.mutationId, + taskContentDigest: current.contentDigest, + }, + ); + publicationHash.update('\0').update(itemDigest); + client + .prepare( + `INSERT INTO "QingLong3SecretConfigApplicationTasks" ("application_mutation_id", "ordinal", "project_id", "adoption_mutation_id", "adoption_row_ordinal", "task_id", "previous_revision", "previous_content_digest", "task_revision", "task_mutation_id", "task_content_digest", "item_digest") VALUES (?, ?, ?, ?, ?, ?, 1, ?, 2, ?, ?, ?)`, + ) + .run( + command.mutationId, + ordinal, + command.projectId, + text(proof, 'adoptionMutationId'), + integer(proof, 'rowOrdinal'), + current.taskId, + previous.contentDigest, + current.mutationId, + current.contentDigest, + itemDigest, + ); + taskCount = ordinal; + } + if (taskCount < 1) + throw new LocalSecretConfigApplicationConflictError(); + } + + const triggerRegistry = createBuiltInTriggerSpecSemanticRegistry(); + let triggerCount = 0; + if (taskCount > 0) { + const provenance = client + .prepare( + `SELECT p."adoption_mutation_id" AS "adoptionMutationId", p."row_ordinal" AS "rowOrdinal", p."trigger_ordinal" AS "triggerOrdinal", p."task_id" AS "taskId", p."trigger_id" AS "triggerId", p."trigger_revision" AS "triggerRevision", p."trigger_mutation_id" AS "triggerMutationId", p."trigger_content_digest" AS "triggerContentDigest" FROM "QingLong3LegacyAdoptionTriggers" AS p JOIN "QingLong3LegacyAdoptions" AS a ON a."mutation_id" = p."adoption_mutation_id" AND a."project_id" = p."project_id" WHERE p."project_id" = ? ORDER BY a."created_at_ms", p."adoption_mutation_id", p."row_ordinal", p."trigger_ordinal"`, + ) + .iterate(command.projectId) as Iterable; + let ordinal = 0; + for (const proof of provenance) { + ordinal += 1; + const triggerId = text(proof, 'triggerId'); + const row = client + .prepare( + `SELECT ${TRIGGER_SELECT} FROM "QingLong3Triggers" AS head JOIN "QingLong3TriggerRevisions" AS revision ON revision."project_id" = head."project_id" AND revision."trigger_id" = head."trigger_id" AND revision."revision" = head."current_revision" WHERE head."project_id" = ? AND head."trigger_id" = ?`, + ) + .get(command.projectId, triggerId) as Row | undefined; + const taskRow = client + .prepare( + `SELECT ${TASK_SELECT} FROM "QingLong3SecretConfigApplicationTasks" AS item JOIN "QingLong3TaskDefinitions" AS head ON head."project_id" = item."project_id" AND head."task_id" = item."task_id" JOIN "QingLong3TaskDefinitionRevisions" AS revision ON revision."project_id" = item."project_id" AND revision."task_id" = item."task_id" AND revision."revision" = item."task_revision" WHERE item."application_mutation_id" = ? AND item."project_id" = ? AND item."task_id" = ? AND head."current_revision" = item."task_revision"`, + ) + .get( + command.mutationId, + command.projectId, + text(proof, 'taskId'), + ) as Row | undefined; + if (!row || !taskRow) + throw new LocalSecretConfigApplicationConflictError(); + const task = taskFromRow(taskRow); + const previous = triggerFromRow(row); + if ( + previous.revision !== 1 || + previous.mutationId !== text(proof, 'triggerMutationId') || + previous.contentDigest !== text(proof, 'triggerContentDigest') + ) + throw new LocalSecretConfigApplicationConflictError(); + const base = normalizeAppendTriggerRevisionCommand({ + projectId: command.projectId, + triggerId, + expectedRevision: 1, + mutationId: deterministicMutationId( + command.mutationId, + 'trigger', + triggerId, + ), + taskId: task.taskId, + taskRevision: task.revision, + taskContentDigest: task.contentDigest, + spec: previous.spec, + enabled: previous.enabled, + occurredAtMs: command.appliedAtMs, + }); + const spec = triggerRegistry.normalize({ + projectId: base.projectId, + triggerId: base.triggerId, + taskId: base.taskId, + taskRevision: base.taskRevision, + spec: base.spec, + }); + const current = createTriggerRecord( + { ...base, spec }, + previous.createdAtMs, + ); + client + .prepare( + `INSERT INTO "QingLong3TriggerRevisions" ("project_id", "trigger_id", "revision", "mutation_id", "task_id", "task_revision", "task_content_digest", "spec_json", "enabled", "content_digest", "created_at_ms") VALUES (?, ?, 2, ?, ?, 2, ?, ?, ?, ?, ?)`, + ) + .run( + current.projectId, + current.triggerId, + current.mutationId, + current.taskId, + current.taskContentDigest, + JSON.stringify(current.spec), + current.enabled ? 1 : 0, + current.contentDigest, + current.updatedAtMs, + ); + const updated = client + .prepare( + `UPDATE "QingLong3Triggers" SET "current_revision" = 2, "updated_at_ms" = ? WHERE "project_id" = ? AND "trigger_id" = ? AND "current_revision" = 1 AND "task_id" = ?`, + ) + .run( + current.updatedAtMs, + current.projectId, + current.triggerId, + current.taskId, + ); + if (updated.changes !== 1) + throw new LocalSecretConfigApplicationConflictError(); + client + .prepare( + `INSERT INTO "QingLong3LocalTriggerSchedules" ("project_id", "trigger_id", "trigger_revision", "next_fire_at_ms", "last_scheduled_at_ms", "state_version", "updated_at_ms") VALUES (?, ?, 2, NULL, NULL, 0, ?) ON CONFLICT ("project_id", "trigger_id") DO UPDATE SET "trigger_revision" = 2, "next_fire_at_ms" = NULL, "last_scheduled_at_ms" = NULL, "state_version" = "QingLong3LocalTriggerSchedules"."state_version" + 1, "updated_at_ms" = excluded."updated_at_ms"`, + ) + .run(current.projectId, current.triggerId, current.updatedAtMs); + const itemDigest = sha256( + 'qinglong3.secret-config-application-trigger.v1\0', + { + ordinal, + adoptionMutationId: text(proof, 'adoptionMutationId'), + rowOrdinal: integer(proof, 'rowOrdinal'), + triggerOrdinal: integer(proof, 'triggerOrdinal'), + triggerId, + previousContentDigest: previous.contentDigest, + triggerMutationId: current.mutationId, + triggerContentDigest: current.contentDigest, + }, + ); + publicationHash.update('\0').update(itemDigest); + client + .prepare( + `INSERT INTO "QingLong3SecretConfigApplicationTriggers" ("application_mutation_id", "ordinal", "project_id", "adoption_mutation_id", "adoption_row_ordinal", "adoption_trigger_ordinal", "task_id", "task_revision", "trigger_id", "previous_revision", "previous_content_digest", "trigger_revision", "trigger_mutation_id", "trigger_content_digest", "item_digest") VALUES (?, ?, ?, ?, ?, ?, ?, 2, ?, 1, ?, 2, ?, ?, ?)`, + ) + .run( + command.mutationId, + ordinal, + command.projectId, + text(proof, 'adoptionMutationId'), + integer(proof, 'rowOrdinal'), + integer(proof, 'triggerOrdinal'), + current.taskId, + current.triggerId, + previous.contentDigest, + current.mutationId, + current.contentDigest, + itemDigest, + ); + triggerCount = ordinal; + } + const missing = client + .prepare( + `SELECT 1 FROM "QingLong3SecretConfigApplicationTasks" AS item JOIN "QingLong3LegacyAdoptionTasks" AS proof ON proof."adoption_mutation_id" = item."adoption_mutation_id" AND proof."row_ordinal" = item."adoption_row_ordinal" WHERE item."application_mutation_id" = ? AND proof."trigger_count" <> (SELECT count(*) FROM "QingLong3LegacyAdoptionTriggers" AS trigger WHERE trigger."adoption_mutation_id" = proof."adoption_mutation_id" AND trigger."row_ordinal" = proof."row_ordinal") LIMIT 1`, + ) + .get(command.mutationId); + if (missing) throw new LocalSecretConfigApplicationConflictError(); + } + + const publicationDigest = publicationHash.digest('hex'); + const payload = { + schema: + 'qinglong/local-secret-config-application-receipt@v1' as const, + mutationId: command.mutationId, + projectId: command.projectId, + profile: command.profile, + secretConfigPlanDigest: command.secretConfigPlanDigest, + decisionDigest: command.decisionDigest, + candidateSetDigest: command.candidateSetDigest, + automationAdoptionSetDigest: command.automationAdoptionSetDigest, + activeBindingCount: normalized.activeBindingCount, + disabledPreservationCount: normalized.disabledPreservationCount, + taskCount, + triggerCount, + publicationDigest, + auditEventId: command.audit.eventId, + appliedAtMs: command.appliedAtMs, + physicalErasureGuaranteed: false as const, + }; + const receipt = Object.freeze({ + ...payload, + receiptDigest: sha256( + 'qinglong3.secret-config-application-receipt.v1\0', + payload, + ), + }); + insertAudit(client, normalized.audit); + client + .prepare( + `INSERT INTO "QingLong3SecretConfigApplications" ("mutation_id", "project_id", "profile", "secret_config_plan_digest", "decision_digest", "candidate_set_digest", "automation_adoption_set_digest", "active_binding_count", "disabled_preservation_count", "task_count", "trigger_count", "publication_digest", "audit_event_id", "applied_at_ms", "receipt_digest", "receipt_json") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + command.mutationId, + command.projectId, + command.profile, + command.secretConfigPlanDigest, + command.decisionDigest, + command.candidateSetDigest, + command.automationAdoptionSetDigest, + normalized.activeBindingCount, + normalized.disabledPreservationCount, + taskCount, + triggerCount, + publicationDigest, + command.audit.eventId, + command.appliedAtMs, + receipt.receiptDigest, + JSON.stringify(receipt), + ); + await command.confirmExternalAuthority(); + const stored = findApplication(client, command.mutationId); + if (!stored || !sameReplay(stored, command, normalized)) + throw new LocalSecretConfigApplicationUnavailableError(); + client.exec('COMMIT'); + began = false; + return Object.freeze({ + status: 'inserted' as const, + application: stored, + }); + } catch (error) { + if (began && client.isTransaction) + try { + client.exec('ROLLBACK'); + } catch { + /* preserve original */ + } + if ( + error instanceof LocalSecretConfigApplicationConflictError || + error instanceof + LocalSecretConfigApplicationAuthorizationFenceConflictError || + error instanceof LocalSecretConfigApplicationUnavailableError + ) + throw error; + if ( + error && + typeof error === 'object' && + 'code' in error && + typeof error.code === 'string' && + error.code.startsWith('SQLITE_CONSTRAINT') + ) + throw new LocalSecretConfigApplicationConflictError(); + throw new LocalSecretConfigApplicationUnavailableError(error); + } + }, + () => new LocalSecretConfigApplicationUnavailableError(), + ); + } +} + +export interface LocalSqliteSecretConfigApplicationDatabase { + readonly profile: LocalSqliteProfile; + readonly readiness: LocalSqliteReadinessEvidence; + readonly projectPolicy: ProjectPolicyRepository; + readonly securityAudit: SecurityAuditSink; + readonly publisher: LocalSqliteSecretConfigApplicationPublisher; + close(): Promise; +} + +export async function openLocalSqliteSecretConfigApplicationDatabase( + options: LocalSqliteDatabaseOptions, +): Promise { + assertLocalSqliteOptions(options); + assertLocalSqlitePathBoundary(options.databasePath, false); + const client = openLocalSqliteClient(options, false); + try { + const readiness = await auditLocalSqliteReadiness(client); + const authority = new LocalSqliteOperationAuthority(client); + const security = new LocalSqliteSecurityAuthorityStore(authority); + let closePromise: Promise | undefined; + return Object.freeze({ + profile: options.profile, + readiness, + projectPolicy: Object.freeze({ + resolve: (...args: Parameters) => + security.resolve(...args), + append: (...args: Parameters) => + security.append(...args), + }), + securityAudit: security, + publisher: new LocalSqliteSecretConfigApplicationPublisher(authority), + close() { + if (!closePromise) closePromise = authority.close(); + return closePromise; + }, + }); + } catch (error) { + if (client.isOpen) client.close(); + throw error; + } +} diff --git a/packages/ql3-local-sqlite/src/migration/migration.ts b/packages/ql3-local-sqlite/src/migration/migration.ts index 6fe652ae..1ed6cdae 100644 --- a/packages/ql3-local-sqlite/src/migration/migration.ts +++ b/packages/ql3-local-sqlite/src/migration/migration.ts @@ -112,6 +112,8 @@ import { local0099LegacyDataDirectoryAdoptionsMigration } from '../migrations/00 import { local0100CapabilityV50Migration } from '../migrations/0100-capability-v50'; import { local0101LegacyAdoptionProvenanceMigration } from '../migrations/0101-legacy-adoption-provenance'; import { local0102CapabilityV51Migration } from '../migrations/0102-capability-v51'; +import { local0103SecretConfigApplicationsMigration } from '../migrations/0103-secret-config-applications'; +import { local0104CapabilityV52Migration } from '../migrations/0104-capability-v52'; import type { LocalSqliteMigrationContext } from '../migrations/sqlMigration'; import { LOCAL_SQLITE_MIGRATION_STREAM_ID, @@ -236,6 +238,8 @@ export const localSqliteMigrationDefinition: MigrationStreamDefinition= 0 + ), + CONSTRAINT ql3_secret_config_application_digest_check CHECK ( + length("secret_config_plan_digest") = 64 AND + "secret_config_plan_digest" NOT GLOB '*[^0-9a-f]*' AND + length("decision_digest") = 64 AND + "decision_digest" NOT GLOB '*[^0-9a-f]*' AND + length("candidate_set_digest") = 64 AND + "candidate_set_digest" NOT GLOB '*[^0-9a-f]*' AND + length("automation_adoption_set_digest") = 64 AND + "automation_adoption_set_digest" NOT GLOB '*[^0-9a-f]*' AND + length("publication_digest") = 64 AND + "publication_digest" NOT GLOB '*[^0-9a-f]*' AND + length("receipt_digest") = 64 AND + "receipt_digest" NOT GLOB '*[^0-9a-f]*' + ) +) + `, + ` +CREATE UNIQUE INDEX "ql3_secret_config_applications_plan_uidx" +ON "QingLong3SecretConfigApplications" ("secret_config_plan_digest") + `, + ` +CREATE UNIQUE INDEX "ql3_secret_config_applications_decision_uidx" +ON "QingLong3SecretConfigApplications" ("decision_digest") + `, + ` +CREATE UNIQUE INDEX "ql3_secret_config_applications_receipt_uidx" +ON "QingLong3SecretConfigApplications" ("receipt_digest") + `, + ` +CREATE INDEX "ql3_secret_config_applications_project_time_idx" +ON "QingLong3SecretConfigApplications" ("project_id", "applied_at_ms") + `, + ` +CREATE TABLE "QingLong3SecretConfigApplicationSecrets" ( + "application_mutation_id" TEXT NOT NULL, + "ordinal" INTEGER NOT NULL, + "project_id" TEXT NOT NULL, + "disposition" TEXT NOT NULL, + "candidate_digest" TEXT NOT NULL, + "source_set_digest" TEXT NOT NULL, + "environment_name" TEXT, + "secret_name" TEXT NOT NULL, + "secret_version" INTEGER NOT NULL, + "secret_mutation_id" TEXT NOT NULL, + "secret_ref" TEXT NOT NULL, + "item_digest" TEXT NOT NULL, + PRIMARY KEY ("application_mutation_id", "ordinal"), + CONSTRAINT ql3_secret_config_secret_parent_fk + FOREIGN KEY ("application_mutation_id") + REFERENCES "QingLong3SecretConfigApplications" ("mutation_id") + ON DELETE RESTRICT ON UPDATE RESTRICT + DEFERRABLE INITIALLY DEFERRED, + CONSTRAINT ql3_secret_config_secret_envelope_fk + FOREIGN KEY ("project_id", "secret_name", "secret_version") + REFERENCES "QingLong3LocalSecretEnvelopes" ( + "project_id", "secret_name", "version" + ) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT ql3_secret_config_secret_identity_check CHECK ( + "ordinal" BETWEEN 1 AND 768 AND + "disposition" IN ('active_binding', 'disabled_preservation') AND + (("disposition" = 'active_binding' AND "environment_name" IS NOT NULL) OR + ("disposition" = 'disabled_preservation' AND "environment_name" IS NULL)) AND + "secret_version" = 1 AND + length("secret_mutation_id") = 36 AND + replace("secret_mutation_id", '-', '') NOT GLOB '*[^0-9a-f]*' + ), + CONSTRAINT ql3_secret_config_secret_digest_check CHECK ( + length("candidate_digest") = 64 AND + "candidate_digest" NOT GLOB '*[^0-9a-f]*' AND + length("source_set_digest") = 64 AND + "source_set_digest" NOT GLOB '*[^0-9a-f]*' AND + length("item_digest") = 64 AND + "item_digest" NOT GLOB '*[^0-9a-f]*' + ) +) + `, + ` +CREATE UNIQUE INDEX "ql3_secret_config_secrets_mutation_uidx" +ON "QingLong3SecretConfigApplicationSecrets" ("secret_mutation_id") + `, + ` +CREATE UNIQUE INDEX "ql3_secret_config_secrets_candidate_uidx" +ON "QingLong3SecretConfigApplicationSecrets" ("candidate_digest") + `, + ` +CREATE UNIQUE INDEX "ql3_secret_config_secrets_target_uidx" +ON "QingLong3SecretConfigApplicationSecrets" ("project_id", "secret_name") + `, + ` +CREATE TABLE "QingLong3SecretConfigApplicationTasks" ( + "application_mutation_id" TEXT NOT NULL, + "ordinal" INTEGER NOT NULL, + "project_id" TEXT NOT NULL, + "adoption_mutation_id" TEXT NOT NULL, + "adoption_row_ordinal" INTEGER NOT NULL, + "task_id" TEXT NOT NULL, + "previous_revision" INTEGER NOT NULL, + "previous_content_digest" TEXT NOT NULL, + "task_revision" INTEGER NOT NULL, + "task_mutation_id" TEXT NOT NULL, + "task_content_digest" TEXT NOT NULL, + "item_digest" TEXT NOT NULL, + PRIMARY KEY ("application_mutation_id", "ordinal"), + CONSTRAINT ql3_secret_config_task_parent_fk + FOREIGN KEY ("application_mutation_id") + REFERENCES "QingLong3SecretConfigApplications" ("mutation_id") + ON DELETE RESTRICT ON UPDATE RESTRICT + DEFERRABLE INITIALLY DEFERRED, + CONSTRAINT ql3_secret_config_task_adoption_fk + FOREIGN KEY ( + "adoption_mutation_id", "adoption_row_ordinal", "project_id", + "task_id", "previous_revision" + ) REFERENCES "QingLong3LegacyAdoptionTasks" ( + "adoption_mutation_id", "row_ordinal", "project_id", "task_id", + "task_revision" + ) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT ql3_secret_config_task_revision_fk + FOREIGN KEY ("project_id", "task_id", "task_revision") + REFERENCES "QingLong3TaskDefinitionRevisions" ( + "project_id", "task_id", "revision" + ) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT ql3_secret_config_task_identity_check CHECK ( + "ordinal" BETWEEN 1 AND 100000 AND + "adoption_row_ordinal" BETWEEN 1 AND 100000 AND + "previous_revision" = 1 AND + "task_revision" = 2 AND + length("task_mutation_id") = 36 AND + replace("task_mutation_id", '-', '') NOT GLOB '*[^0-9a-f]*' + ), + CONSTRAINT ql3_secret_config_task_digest_check CHECK ( + length("previous_content_digest") = 64 AND + "previous_content_digest" NOT GLOB '*[^0-9a-f]*' AND + length("task_content_digest") = 64 AND + "task_content_digest" NOT GLOB '*[^0-9a-f]*' AND + length("item_digest") = 64 AND + "item_digest" NOT GLOB '*[^0-9a-f]*' + ) +) + `, + ` +CREATE UNIQUE INDEX "ql3_secret_config_tasks_identity_uidx" +ON "QingLong3SecretConfigApplicationTasks" ("project_id", "task_id") + `, + ` +CREATE UNIQUE INDEX "ql3_secret_config_tasks_mutation_uidx" +ON "QingLong3SecretConfigApplicationTasks" ("task_mutation_id") + `, + ` +CREATE TABLE "QingLong3SecretConfigApplicationTriggers" ( + "application_mutation_id" TEXT NOT NULL, + "ordinal" INTEGER NOT NULL, + "project_id" TEXT NOT NULL, + "adoption_mutation_id" TEXT NOT NULL, + "adoption_row_ordinal" INTEGER NOT NULL, + "adoption_trigger_ordinal" INTEGER NOT NULL, + "task_id" TEXT NOT NULL, + "task_revision" INTEGER NOT NULL, + "trigger_id" TEXT NOT NULL, + "previous_revision" INTEGER NOT NULL, + "previous_content_digest" TEXT NOT NULL, + "trigger_revision" INTEGER NOT NULL, + "trigger_mutation_id" TEXT NOT NULL, + "trigger_content_digest" TEXT NOT NULL, + "item_digest" TEXT NOT NULL, + PRIMARY KEY ("application_mutation_id", "ordinal"), + CONSTRAINT ql3_secret_config_trigger_parent_fk + FOREIGN KEY ("application_mutation_id") + REFERENCES "QingLong3SecretConfigApplications" ("mutation_id") + ON DELETE RESTRICT ON UPDATE RESTRICT + DEFERRABLE INITIALLY DEFERRED, + CONSTRAINT ql3_secret_config_trigger_adoption_fk + FOREIGN KEY ( + "adoption_mutation_id", "adoption_row_ordinal", + "adoption_trigger_ordinal" + ) REFERENCES "QingLong3LegacyAdoptionTriggers" ( + "adoption_mutation_id", "row_ordinal", "trigger_ordinal" + ) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT ql3_secret_config_trigger_revision_fk + FOREIGN KEY ("project_id", "trigger_id", "trigger_revision") + REFERENCES "QingLong3TriggerRevisions" ( + "project_id", "trigger_id", "revision" + ) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT ql3_secret_config_trigger_identity_check CHECK ( + "ordinal" BETWEEN 1 AND 500000 AND + "adoption_row_ordinal" BETWEEN 1 AND 100000 AND + "adoption_trigger_ordinal" BETWEEN 1 AND 500000 AND + "task_revision" = 2 AND + "previous_revision" = 1 AND + "trigger_revision" = 2 AND + length("trigger_mutation_id") = 36 AND + replace("trigger_mutation_id", '-', '') NOT GLOB '*[^0-9a-f]*' + ), + CONSTRAINT ql3_secret_config_trigger_digest_check CHECK ( + length("previous_content_digest") = 64 AND + "previous_content_digest" NOT GLOB '*[^0-9a-f]*' AND + length("trigger_content_digest") = 64 AND + "trigger_content_digest" NOT GLOB '*[^0-9a-f]*' AND + length("item_digest") = 64 AND + "item_digest" NOT GLOB '*[^0-9a-f]*' + ) +) + `, + ` +CREATE UNIQUE INDEX "ql3_secret_config_triggers_identity_uidx" +ON "QingLong3SecretConfigApplicationTriggers" ("project_id", "trigger_id") + `, + ` +CREATE UNIQUE INDEX "ql3_secret_config_triggers_mutation_uidx" +ON "QingLong3SecretConfigApplicationTriggers" ("trigger_mutation_id") + `, + ], + }); diff --git a/packages/ql3-local-sqlite/src/migrations/0104-capability-v52.ts b/packages/ql3-local-sqlite/src/migrations/0104-capability-v52.ts new file mode 100644 index 00000000..cba14062 --- /dev/null +++ b/packages/ql3-local-sqlite/src/migrations/0104-capability-v52.ts @@ -0,0 +1,14 @@ +import { CAPABILITIES_V51 } from './0102-capability-v51'; +import { defineLocalSqliteMigration } from './sqlMigration'; + +export const CAPABILITIES_V52 = CAPABILITIES_V51.replace( + '"legacy_adoption_provenance":1,', + '"legacy_adoption_provenance":1,"secret_config_application":1,', +); + +export const local0104CapabilityV52Migration = defineLocalSqliteMigration({ + id: '0104-capability-v52', + statements: [ + `UPDATE "QingLong3SchemaCapabilities" SET contract_version = 52, migration_id = '0103-secret-config-applications', capabilities = '${CAPABILITIES_V52}', updated_at_ms = CAST(unixepoch('subsec') * 1000 AS INTEGER) WHERE contract_name = 'local-control-core' AND contract_version = 51 AND migration_id = '0101-legacy-adoption-provenance' AND capabilities = '${CAPABILITIES_V51}'`, + ], +}); diff --git a/packages/ql3-local-sqlite/src/readiness/readiness.ts b/packages/ql3-local-sqlite/src/readiness/readiness.ts index 27eff197..cdadf8f4 100644 --- a/packages/ql3-local-sqlite/src/readiness/readiness.ts +++ b/packages/ql3-local-sqlite/src/readiness/readiness.ts @@ -12,7 +12,7 @@ import { } from '../run/stepRunSchemaContract'; export const LOCAL_SQLITE_CONTRACT_NAME = 'local-control-core'; -export const LOCAL_SQLITE_CONTRACT_VERSION = 51; +export const LOCAL_SQLITE_CONTRACT_VERSION = 52; const LEGACY_DATA_DIRECTORY_ADOPTION_TRIGGERS = Object.freeze([ Object.freeze({ @@ -1439,6 +1439,96 @@ const REQUIRED_SCHEMA = Object.freeze({ 'ql3_legacy_adoption_triggers_item_uidx', ]), }), + QingLong3SecretConfigApplications: Object.freeze({ + columns: Object.freeze([ + 'mutation_id', + 'project_id', + 'profile', + 'secret_config_plan_digest', + 'decision_digest', + 'candidate_set_digest', + 'automation_adoption_set_digest', + 'active_binding_count', + 'disabled_preservation_count', + 'task_count', + 'trigger_count', + 'publication_digest', + 'audit_event_id', + 'applied_at_ms', + 'receipt_digest', + 'receipt_json', + ]), + indexes: Object.freeze([ + 'ql3_secret_config_applications_plan_uidx', + 'ql3_secret_config_applications_decision_uidx', + 'ql3_secret_config_applications_receipt_uidx', + 'ql3_secret_config_applications_project_time_idx', + ]), + }), + QingLong3SecretConfigApplicationSecrets: Object.freeze({ + columns: Object.freeze([ + 'application_mutation_id', + 'ordinal', + 'project_id', + 'disposition', + 'candidate_digest', + 'source_set_digest', + 'environment_name', + 'secret_name', + 'secret_version', + 'secret_mutation_id', + 'secret_ref', + 'item_digest', + ]), + indexes: Object.freeze([ + 'ql3_secret_config_secrets_mutation_uidx', + 'ql3_secret_config_secrets_candidate_uidx', + 'ql3_secret_config_secrets_target_uidx', + ]), + }), + QingLong3SecretConfigApplicationTasks: Object.freeze({ + columns: Object.freeze([ + 'application_mutation_id', + 'ordinal', + 'project_id', + 'adoption_mutation_id', + 'adoption_row_ordinal', + 'task_id', + 'previous_revision', + 'previous_content_digest', + 'task_revision', + 'task_mutation_id', + 'task_content_digest', + 'item_digest', + ]), + indexes: Object.freeze([ + 'ql3_secret_config_tasks_identity_uidx', + 'ql3_secret_config_tasks_mutation_uidx', + ]), + }), + QingLong3SecretConfigApplicationTriggers: Object.freeze({ + columns: Object.freeze([ + 'application_mutation_id', + 'ordinal', + 'project_id', + 'adoption_mutation_id', + 'adoption_row_ordinal', + 'adoption_trigger_ordinal', + 'task_id', + 'task_revision', + 'trigger_id', + 'previous_revision', + 'previous_content_digest', + 'trigger_revision', + 'trigger_mutation_id', + 'trigger_content_digest', + 'item_digest', + ]), + indexes: Object.freeze([ + 'ql3_secret_config_triggers_identity_uidx', + 'ql3_secret_config_triggers_mutation_uidx', + ]), + }), QingLong3LegacyDataDirectoryAdoptions: Object.freeze({ columns: Object.freeze([ 'mutation_id', @@ -2699,10 +2789,10 @@ export async function auditLocalSqliteReadiness( !capability || capability.contract_name !== LOCAL_SQLITE_CONTRACT_NAME || capability.contract_version !== LOCAL_SQLITE_CONTRACT_VERSION || - capability.migration_id !== '0101-legacy-adoption-provenance' || + capability.migration_id !== '0103-secret-config-applications' || typeof capability.capabilities !== 'string' || capability.capabilities !== - '{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"legacy_adoption_provenance":1,"legacy_data_directory_adoption":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_secret_binding":1,"plugin_package_secret_binding_transition":1,"plugin_package_secret_binding_transition_receipt":1,"plugin_package_secret_materialization":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' || + '{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"legacy_adoption_provenance":1,"secret_config_application":1,"legacy_data_directory_adoption":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_secret_binding":1,"plugin_package_secret_binding_transition":1,"plugin_package_secret_binding_transition_receipt":1,"plugin_package_secret_materialization":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' || typeof capability.updated_at_ms !== 'number' || !Number.isSafeInteger(capability.updated_at_ms) || capability.updated_at_ms < 0 diff --git a/packages/ql3-local-sqlite/src/storage/schema.ts b/packages/ql3-local-sqlite/src/storage/schema.ts index 5ec2f14b..864443a9 100644 --- a/packages/ql3-local-sqlite/src/storage/schema.ts +++ b/packages/ql3-local-sqlite/src/storage/schema.ts @@ -3472,6 +3472,259 @@ export const legacyAdoptionTriggers = sqliteTable( ], ); +export const secretConfigApplications = sqliteTable( + 'QingLong3SecretConfigApplications', + { + mutationId: text('mutation_id').primaryKey(), + projectId: text('project_id') + .notNull() + .references(() => localProjects.id, { + onDelete: 'restrict', + onUpdate: 'restrict', + }), + profile: text('profile').notNull(), + secretConfigPlanDigest: text('secret_config_plan_digest').notNull(), + decisionDigest: text('decision_digest').notNull(), + candidateSetDigest: text('candidate_set_digest').notNull(), + automationAdoptionSetDigest: text( + 'automation_adoption_set_digest', + ).notNull(), + activeBindingCount: integer('active_binding_count').notNull(), + disabledPreservationCount: integer('disabled_preservation_count').notNull(), + taskCount: integer('task_count').notNull(), + triggerCount: integer('trigger_count').notNull(), + publicationDigest: text('publication_digest').notNull(), + auditEventId: text('audit_event_id') + .notNull() + .references(() => localSecurityAuditEvents.eventId, { + onDelete: 'restrict', + onUpdate: 'restrict', + }), + appliedAtMs: integer('applied_at_ms').notNull(), + receiptDigest: text('receipt_digest').notNull(), + receiptJson: text('receipt_json', { mode: 'json' }) + .$type>() + .notNull(), + }, + (table) => [ + check( + 'ql3_secret_config_application_identity_check', + sql`length(${table.mutationId}) = 36 and replace(${table.mutationId}, '-', '') not glob '*[^0-9a-f]*' and length(${table.projectId}) between 1 and 128 and ${table.profile} in ('edge', 'standalone') and ${table.activeBindingCount} between 0 and 256 and ${table.disabledPreservationCount} between 0 and 512 and ${table.taskCount} between 0 and 100000 and ${table.triggerCount} between 0 and 500000 and ${table.appliedAtMs} >= 0`, + ), + check( + 'ql3_secret_config_application_digest_check', + sql`length(${table.secretConfigPlanDigest}) = 64 and ${table.secretConfigPlanDigest} not glob '*[^0-9a-f]*' and length(${table.decisionDigest}) = 64 and ${table.decisionDigest} not glob '*[^0-9a-f]*' and length(${table.candidateSetDigest}) = 64 and ${table.candidateSetDigest} not glob '*[^0-9a-f]*' and length(${table.automationAdoptionSetDigest}) = 64 and ${table.automationAdoptionSetDigest} not glob '*[^0-9a-f]*' and length(${table.publicationDigest}) = 64 and ${table.publicationDigest} not glob '*[^0-9a-f]*' and length(${table.receiptDigest}) = 64 and ${table.receiptDigest} not glob '*[^0-9a-f]*'`, + ), + uniqueIndex('ql3_secret_config_applications_plan_uidx').on( + table.secretConfigPlanDigest, + ), + uniqueIndex('ql3_secret_config_applications_decision_uidx').on( + table.decisionDigest, + ), + uniqueIndex('ql3_secret_config_applications_receipt_uidx').on( + table.receiptDigest, + ), + index('ql3_secret_config_applications_project_time_idx').on( + table.projectId, + table.appliedAtMs, + ), + ], +); + +export const secretConfigApplicationSecrets = sqliteTable( + 'QingLong3SecretConfigApplicationSecrets', + { + applicationMutationId: text('application_mutation_id').notNull(), + ordinal: integer('ordinal').notNull(), + projectId: text('project_id').notNull(), + disposition: text('disposition').notNull(), + candidateDigest: text('candidate_digest').notNull(), + sourceSetDigest: text('source_set_digest').notNull(), + environmentName: text('environment_name'), + secretName: text('secret_name').notNull(), + secretVersion: integer('secret_version').notNull(), + secretMutationId: text('secret_mutation_id').notNull(), + secretRef: text('secret_ref').notNull(), + itemDigest: text('item_digest').notNull(), + }, + (table) => [ + primaryKey({ columns: [table.applicationMutationId, table.ordinal] }), + foreignKey({ + columns: [table.applicationMutationId], + foreignColumns: [secretConfigApplications.mutationId], + }) + .onDelete('restrict') + .onUpdate('restrict'), + foreignKey({ + columns: [table.projectId, table.secretName, table.secretVersion], + foreignColumns: [ + localSecretEnvelopes.projectId, + localSecretEnvelopes.name, + localSecretEnvelopes.version, + ], + }) + .onDelete('restrict') + .onUpdate('restrict'), + check( + 'ql3_secret_config_secret_identity_check', + sql`${table.ordinal} between 1 and 768 and ${table.disposition} in ('active_binding', 'disabled_preservation') and ((${table.disposition} = 'active_binding' and ${table.environmentName} is not null) or (${table.disposition} = 'disabled_preservation' and ${table.environmentName} is null)) and ${table.secretVersion} = 1 and length(${table.secretMutationId}) = 36 and replace(${table.secretMutationId}, '-', '') not glob '*[^0-9a-f]*'`, + ), + check( + 'ql3_secret_config_secret_digest_check', + sql`length(${table.candidateDigest}) = 64 and ${table.candidateDigest} not glob '*[^0-9a-f]*' and length(${table.sourceSetDigest}) = 64 and ${table.sourceSetDigest} not glob '*[^0-9a-f]*' and length(${table.itemDigest}) = 64 and ${table.itemDigest} not glob '*[^0-9a-f]*'`, + ), + uniqueIndex('ql3_secret_config_secrets_mutation_uidx').on( + table.secretMutationId, + ), + uniqueIndex('ql3_secret_config_secrets_candidate_uidx').on( + table.candidateDigest, + ), + uniqueIndex('ql3_secret_config_secrets_target_uidx').on( + table.projectId, + table.secretName, + ), + ], +); + +export const secretConfigApplicationTasks = sqliteTable( + 'QingLong3SecretConfigApplicationTasks', + { + applicationMutationId: text('application_mutation_id').notNull(), + ordinal: integer('ordinal').notNull(), + projectId: text('project_id').notNull(), + adoptionMutationId: text('adoption_mutation_id').notNull(), + adoptionRowOrdinal: integer('adoption_row_ordinal').notNull(), + taskId: text('task_id').notNull(), + previousRevision: integer('previous_revision').notNull(), + previousContentDigest: text('previous_content_digest').notNull(), + taskRevision: integer('task_revision').notNull(), + taskMutationId: text('task_mutation_id').notNull(), + taskContentDigest: text('task_content_digest').notNull(), + itemDigest: text('item_digest').notNull(), + }, + (table) => [ + primaryKey({ columns: [table.applicationMutationId, table.ordinal] }), + foreignKey({ + columns: [table.applicationMutationId], + foreignColumns: [secretConfigApplications.mutationId], + }) + .onDelete('restrict') + .onUpdate('restrict'), + foreignKey({ + columns: [ + table.adoptionMutationId, + table.adoptionRowOrdinal, + table.projectId, + table.taskId, + table.previousRevision, + ], + foreignColumns: [ + legacyAdoptionTasks.adoptionMutationId, + legacyAdoptionTasks.rowOrdinal, + legacyAdoptionTasks.projectId, + legacyAdoptionTasks.taskId, + legacyAdoptionTasks.taskRevision, + ], + }) + .onDelete('restrict') + .onUpdate('restrict'), + foreignKey({ + columns: [table.projectId, table.taskId, table.taskRevision], + foreignColumns: [ + taskDefinitionRevisions.projectId, + taskDefinitionRevisions.taskId, + taskDefinitionRevisions.revision, + ], + }) + .onDelete('restrict') + .onUpdate('restrict'), + check( + 'ql3_secret_config_task_identity_check', + sql`${table.ordinal} between 1 and 100000 and ${table.adoptionRowOrdinal} between 1 and 100000 and ${table.previousRevision} = 1 and ${table.taskRevision} = 2 and length(${table.taskMutationId}) = 36 and replace(${table.taskMutationId}, '-', '') not glob '*[^0-9a-f]*'`, + ), + check( + 'ql3_secret_config_task_digest_check', + sql`length(${table.previousContentDigest}) = 64 and ${table.previousContentDigest} not glob '*[^0-9a-f]*' and length(${table.taskContentDigest}) = 64 and ${table.taskContentDigest} not glob '*[^0-9a-f]*' and length(${table.itemDigest}) = 64 and ${table.itemDigest} not glob '*[^0-9a-f]*'`, + ), + uniqueIndex('ql3_secret_config_tasks_identity_uidx').on( + table.projectId, + table.taskId, + ), + uniqueIndex('ql3_secret_config_tasks_mutation_uidx').on( + table.taskMutationId, + ), + ], +); + +export const secretConfigApplicationTriggers = sqliteTable( + 'QingLong3SecretConfigApplicationTriggers', + { + applicationMutationId: text('application_mutation_id').notNull(), + ordinal: integer('ordinal').notNull(), + projectId: text('project_id').notNull(), + adoptionMutationId: text('adoption_mutation_id').notNull(), + adoptionRowOrdinal: integer('adoption_row_ordinal').notNull(), + adoptionTriggerOrdinal: integer('adoption_trigger_ordinal').notNull(), + taskId: text('task_id').notNull(), + taskRevision: integer('task_revision').notNull(), + triggerId: text('trigger_id').notNull(), + previousRevision: integer('previous_revision').notNull(), + previousContentDigest: text('previous_content_digest').notNull(), + triggerRevision: integer('trigger_revision').notNull(), + triggerMutationId: text('trigger_mutation_id').notNull(), + triggerContentDigest: text('trigger_content_digest').notNull(), + itemDigest: text('item_digest').notNull(), + }, + (table) => [ + primaryKey({ columns: [table.applicationMutationId, table.ordinal] }), + foreignKey({ + columns: [table.applicationMutationId], + foreignColumns: [secretConfigApplications.mutationId], + }) + .onDelete('restrict') + .onUpdate('restrict'), + foreignKey({ + columns: [ + table.adoptionMutationId, + table.adoptionRowOrdinal, + table.adoptionTriggerOrdinal, + ], + foreignColumns: [ + legacyAdoptionTriggers.adoptionMutationId, + legacyAdoptionTriggers.rowOrdinal, + legacyAdoptionTriggers.triggerOrdinal, + ], + }) + .onDelete('restrict') + .onUpdate('restrict'), + foreignKey({ + columns: [table.projectId, table.triggerId, table.triggerRevision], + foreignColumns: [ + triggerRevisions.projectId, + triggerRevisions.triggerId, + triggerRevisions.revision, + ], + }) + .onDelete('restrict') + .onUpdate('restrict'), + check( + 'ql3_secret_config_trigger_identity_check', + sql`${table.ordinal} between 1 and 500000 and ${table.adoptionRowOrdinal} between 1 and 100000 and ${table.adoptionTriggerOrdinal} between 1 and 500000 and ${table.taskRevision} = 2 and ${table.previousRevision} = 1 and ${table.triggerRevision} = 2 and length(${table.triggerMutationId}) = 36 and replace(${table.triggerMutationId}, '-', '') not glob '*[^0-9a-f]*'`, + ), + check( + 'ql3_secret_config_trigger_digest_check', + sql`length(${table.previousContentDigest}) = 64 and ${table.previousContentDigest} not glob '*[^0-9a-f]*' and length(${table.triggerContentDigest}) = 64 and ${table.triggerContentDigest} not glob '*[^0-9a-f]*' and length(${table.itemDigest}) = 64 and ${table.itemDigest} not glob '*[^0-9a-f]*'`, + ), + uniqueIndex('ql3_secret_config_triggers_identity_uidx').on( + table.projectId, + table.triggerId, + ), + uniqueIndex('ql3_secret_config_triggers_mutation_uidx').on( + table.triggerMutationId, + ), + ], +); + export const legacyDataDirectoryAdoptions = sqliteTable( 'QingLong3LegacyDataDirectoryAdoptions', { @@ -5326,6 +5579,10 @@ export const localSqliteSchema = Object.freeze({ legacyAdoptions, legacyAdoptionTasks, legacyAdoptionTriggers, + secretConfigApplications, + secretConfigApplicationSecrets, + secretConfigApplicationTasks, + secretConfigApplicationTriggers, legacyDataDirectoryAdoptions, legacyDataDirectoryAdoptionSecrets, localIdentitySubjects, diff --git a/packages/ql3-local-sqlite/test/authenticationRead.test.cjs b/packages/ql3-local-sqlite/test/authenticationRead.test.cjs index 800e18a7..0de2b618 100644 --- a/packages/ql3-local-sqlite/test/authenticationRead.test.cjs +++ b/packages/ql3-local-sqlite/test/authenticationRead.test.cjs @@ -25,7 +25,7 @@ test('authentication projection opens the target read-only without journal or fi try { assert.equal(database.profile, 'edge'); assert.equal(database.readiness.contractName, 'local-control-core'); - assert.equal(database.readiness.contractVersion, 51); + assert.equal(database.readiness.contractVersion, 52); assert.equal(await database.apiCredentials.resolve('absent'), null); assert.equal(await database.ownerPepper.resolveKey('absent'), null); } finally { diff --git a/packages/ql3-local-sqlite/test/database.test.cjs b/packages/ql3-local-sqlite/test/database.test.cjs index 1daf2f47..59c9aa34 100644 --- a/packages/ql3-local-sqlite/test/database.test.cjs +++ b/packages/ql3-local-sqlite/test/database.test.cjs @@ -152,9 +152,11 @@ test('creates a reviewed edge database and opens runtime only after readiness', '0100-capability-v50', '0101-legacy-adoption-provenance', '0102-capability-v51', + '0103-secret-config-applications', + '0104-capability-v52', ]); assert.equal(migrated.readiness.contractName, 'local-control-core'); - assert.equal(migrated.readiness.contractVersion, 51); + assert.equal(migrated.readiness.contractVersion, 52); assert.equal(migrated.readiness.journalMode, 'delete'); assert.equal(fs.statSync(databasePath).mode & 0o777, 0o600); @@ -600,8 +602,8 @@ test('backfills v14 execution revisions with a verified independent digest', asy .get(), }, { - contract_version: 51, - migration_id: '0101-legacy-adoption-provenance', + contract_version: 52, + migration_id: '0103-secret-config-applications', }, ); } finally { @@ -788,19 +790,19 @@ test('excludes reviewed optional feature tables while preserving unknown table d const options = { databasePath, profile: 'edge' }; await migrateLocalSqlitePath(options); const client = new DatabaseSync(databasePath); - assert.equal((await auditLocalSqlitePath(options)).tableCount, 85); + assert.equal((await auditLocalSqlitePath(options)).tableCount, 89); client.exec( 'CREATE TABLE "ModelInvocationFeatureHead" (feature_id TEXT PRIMARY KEY)', ); client.close(); - assert.equal((await auditLocalSqlitePath(options)).tableCount, 85); + assert.equal((await auditLocalSqlitePath(options)).tableCount, 89); const unknownClient = new DatabaseSync(databasePath); unknownClient.exec('CREATE TABLE "UserExtensionData" (id TEXT PRIMARY KEY)'); unknownClient.close(); - assert.equal((await auditLocalSqlitePath(options)).tableCount, 86); + assert.equal((await auditLocalSqlitePath(options)).tableCount, 90); const triggerClient = new DatabaseSync(databasePath); triggerClient.exec(` diff --git a/packages/ql3-local-sqlite/test/pluginPackageWorkflowAdmissionRepository.test.cjs b/packages/ql3-local-sqlite/test/pluginPackageWorkflowAdmissionRepository.test.cjs index e78ec44f..3d29a490 100644 --- a/packages/ql3-local-sqlite/test/pluginPackageWorkflowAdmissionRepository.test.cjs +++ b/packages/ql3-local-sqlite/test/pluginPackageWorkflowAdmissionRepository.test.cjs @@ -156,7 +156,7 @@ test('atomically admits one generation-bound Workflow Run and exactly replays it }, { runs: 1, steps: 2, events: 3, mutations: 2, admissions: 1 }, ); - assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 51); + assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 52); }); test('runs an optional authorization guard inside new and replay transactions', async (t) => { @@ -288,7 +288,7 @@ test('exactly replays immutable admission after the Workflow StepRun advances', }, { status: 'running', version: 5, eventSequence: 5 }, ); - assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 51); + assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 52); }); test('fails closed before writing when the exact installation is not active', async (t) => { diff --git a/packages/ql3-local-sqlite/test/pluginPackageWorkflowTaskAttemptAdmissionRepository.test.cjs b/packages/ql3-local-sqlite/test/pluginPackageWorkflowTaskAttemptAdmissionRepository.test.cjs index 8420784c..64ff4b04 100644 --- a/packages/ql3-local-sqlite/test/pluginPackageWorkflowTaskAttemptAdmissionRepository.test.cjs +++ b/packages/ql3-local-sqlite/test/pluginPackageWorkflowTaskAttemptAdmissionRepository.test.cjs @@ -231,7 +231,7 @@ test('atomically admits the exact reconciled local Task revision and replays it' stepAttemptCount: 0, }, ); - assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 51); + assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 52); }); test('bounds candidate paging before SQL and fences cancellation', async (t) => { diff --git a/packages/ql3-local-sqlite/test/rolloutSafety.test.cjs b/packages/ql3-local-sqlite/test/rolloutSafety.test.cjs index bf10d809..263b5d1f 100644 --- a/packages/ql3-local-sqlite/test/rolloutSafety.test.cjs +++ b/packages/ql3-local-sqlite/test/rolloutSafety.test.cjs @@ -40,9 +40,9 @@ test('creates and exactly replays a reviewed rollout backup', async (t) => { await migrateLocalSqlitePath(state); const prepared = await createLocalSqliteRolloutBackup(state); assert.equal(prepared.status, 'prepared'); - assert.equal(prepared.contractVersion, 51); - assert.equal(prepared.writeContractVersion, 51); - assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 51); + assert.equal(prepared.contractVersion, 52); + assert.equal(prepared.writeContractVersion, 52); + assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 52); assert.match(prepared.sha256, /^[0-9a-f]{64}$/); assert.equal(prepared.bytes > 0, true); assert.equal(prepared.pageCount > 0, true); diff --git a/packages/ql3-local-sqlite/test/secretConfigApplicationPublisher.test.cjs b/packages/ql3-local-sqlite/test/secretConfigApplicationPublisher.test.cjs new file mode 100644 index 00000000..6d6f1941 --- /dev/null +++ b/packages/ql3-local-sqlite/test/secretConfigApplicationPublisher.test.cjs @@ -0,0 +1,401 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { DatabaseSync } = require('node:sqlite'); +const { test } = require('node:test'); + +const { migrateLocalSqlitePath } = require('../dist/migration/migration'); +const { + openLocalSqliteAdoptionDatabase, +} = require('@qinglong/local-sqlite/adoption'); +const { + LocalSecretConfigApplicationConflictError, + openLocalSqliteSecretConfigApplicationDatabase, +} = require('@qinglong/local-sqlite/secret-config-application'); + +const SUBJECT = Object.freeze({ type: 'user', id: 'local-owner' }); +const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 }); +const ADOPTION_MUTATION = '12345678-1234-4123-8123-123456789abc'; +const APPLICATION_MUTATION = '87654321-4321-4123-8123-cba987654321'; +const SECRET_MUTATION = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; + +function digest(character) { + return character.repeat(64); +} + +function fixture(t) { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), 'ql3-secret-config-application-'), + ); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return path.join(directory, 'qinglong3.sqlite'); +} + +async function preparedDatabase(t) { + const databasePath = fixture(t); + await migrateLocalSqlitePath({ databasePath, profile: 'edge' }); + const client = new DatabaseSync(databasePath); + client + .prepare( + `INSERT INTO "QingLong3ProjectRoleBindings" ( + "project_id", "subject_type", "subject_id", "version", "state", "role", + "mutation_id", "changed_by_type", "changed_by_id", "created_at_ms" + ) VALUES ('default', 'user', 'local-owner', 1, 'active', 'owner', + 'secret-config-owner-binding', 'user', 'local-owner', 1)`, + ) + .run(); + client.close(); + + const adoption = await openLocalSqliteAdoptionDatabase({ + databasePath, + profile: 'edge', + }); + await adoption.publisher.publish({ + mutationId: ADOPTION_MUTATION, + decisionId: '019f7200-0000-7000-8000-000000000001', + projectId: 'default', + profile: 'edge', + planDigest: digest('1'), + inventoryDigest: digest('2'), + decisionDigest: digest('3'), + receiptDigest: digest('4'), + authorizationFileDigest: digest('5'), + rowCount: 1, + skippedCount: 0, + subject: SUBJECT, + fence: FENCE, + audit: { + eventId: ADOPTION_MUTATION, + requestId: 'automation-adoption', + operationId: 'task.adopt', + projectId: 'default', + subject: SUBJECT, + authenticationId: 'local-console:review', + outcome: 'allowed', + reasons: ['project_role_allowed'], + fence: FENCE, + occurredAtMs: 100, + }, + candidates: [ + { + rowOrdinal: 1, + sourceDigest: digest('6'), + task: { + taskId: 'legacy-cron:1', + name: 'Legacy Task', + kind: 'command', + spec: { + schema: 'qinglong/command@v1', + config: { + command: { kind: 'argv', file: '/bin/echo', args: ['legacy'] }, + }, + }, + labels: { source: 'legacy-adoption' }, + enabled: true, + }, + triggers: [ + { + triggerId: 'legacy-cron:1:cron:1', + spec: { + schema: 'qinglong/cron@v1', + config: { + expression: '0 0 * * *', + timezone: 'UTC', + misfirePolicy: 'skip', + }, + }, + enabled: true, + }, + ], + }, + ], + confirmExternalAuthority() {}, + createdAtMs: 100, + }); + await adoption.close(); + return databasePath; +} + +function applicationCommand(confirmExternalAuthority = () => {}) { + return { + mutationId: APPLICATION_MUTATION, + projectId: 'default', + profile: 'edge', + secretConfigPlanDigest: digest('7'), + decisionDigest: digest('8'), + candidateSetDigest: digest('9'), + automationAdoptionSetDigest: digest('a'), + subject: SUBJECT, + fence: FENCE, + audit: { + eventId: APPLICATION_MUTATION, + requestId: 'secret-config-application', + operationId: 'secret-config.apply', + projectId: 'default', + subject: SUBJECT, + authenticationId: 'local-console:review', + outcome: 'allowed', + reasons: ['project_role_allowed'], + fence: FENCE, + occurredAtMs: 200, + }, + secrets: [ + { + ordinal: 1, + disposition: 'active_binding', + candidateDigest: digest('b'), + sourceSetDigest: digest('c'), + environmentName: 'LEGACY_TOKEN', + envelope: { + projectId: 'default', + name: 'legacy-db-env-bbbbbbbbbbbbbbbb', + version: 1, + mutationId: SECRET_MUTATION, + keyId: 'active-key', + algorithm: 'aes-256-gcm', + nonce: Buffer.alloc(12, 1).toString('base64url'), + ciphertext: Buffer.from('ciphertext').toString('base64url'), + authTag: Buffer.alloc(16, 2).toString('base64url'), + createdAtMs: 200, + }, + audit: { + eventId: SECRET_MUTATION, + requestId: 'secret-config-application', + operationId: 'secret.create', + projectId: 'default', + subject: SUBJECT, + authenticationId: 'local-console:review', + outcome: 'allowed', + reasons: ['project_role_allowed'], + fence: FENCE, + occurredAtMs: 200, + }, + }, + ], + appliedAtMs: 200, + confirmExternalAuthority, + }; +} + +test('atomically binds Secret, Task, dispatch, Trigger, schedule and replay ledger', async (t) => { + const databasePath = await preparedDatabase(t); + const database = await openLocalSqliteSecretConfigApplicationDatabase({ + databasePath, + profile: 'edge', + }); + const command = applicationCommand(); + const inserted = await database.publisher.publish(command); + assert.equal(inserted.status, 'inserted'); + assert.equal(inserted.application.receipt.activeBindingCount, 1); + assert.equal(inserted.application.receipt.taskCount, 1); + assert.equal(inserted.application.receipt.triggerCount, 1); + assert.equal((await database.publisher.publish(command)).status, 'existing'); + await database.close(); + + const client = new DatabaseSync(databasePath, { readOnly: true }); + assert.equal( + client + .prepare( + `SELECT "current_revision" AS revision FROM "QingLong3TaskDefinitions" WHERE "task_id" = 'legacy-cron:1'`, + ) + .get().revision, + 2, + ); + assert.equal( + client + .prepare( + `SELECT "current_revision" AS revision FROM "QingLong3Triggers" WHERE "trigger_id" = 'legacy-cron:1:cron:1'`, + ) + .get().revision, + 2, + ); + assert.equal( + client + .prepare( + `SELECT "trigger_revision" AS revision FROM "QingLong3LocalTriggerSchedules" WHERE "trigger_id" = 'legacy-cron:1:cron:1'`, + ) + .get().revision, + 2, + ); + assert.equal( + client + .prepare( + `SELECT count(*) AS count FROM "QingLong3LocalSecretEnvelopes" WHERE "secret_name" = 'legacy-db-env-bbbbbbbbbbbbbbbb'`, + ) + .get().count, + 1, + ); + assert.equal( + client + .prepare( + `SELECT count(*) AS count FROM "QingLong3LocalTaskExecutionRevisions" WHERE "task_id" = 'legacy-cron:1' AND "task_revision" LIKE 'qltd:v1:2:%'`, + ) + .get().count, + 1, + ); + client.close(); +}); + +test('rolls every database mutation back when the commit authority changes', async (t) => { + const databasePath = await preparedDatabase(t); + const database = await openLocalSqliteSecretConfigApplicationDatabase({ + databasePath, + profile: 'edge', + }); + let checks = 0; + await assert.rejects( + database.publisher.publish( + applicationCommand(() => { + checks += 1; + if (checks === 2) throw new Error('instance head drifted'); + }), + ), + /unavailable/, + ); + await database.close(); + const client = new DatabaseSync(databasePath, { readOnly: true }); + assert.equal( + client + .prepare( + `SELECT count(*) AS count FROM "QingLong3SecretConfigApplications"`, + ) + .get().count, + 0, + ); + assert.equal( + client + .prepare( + `SELECT "current_revision" AS revision FROM "QingLong3TaskDefinitions" WHERE "task_id" = 'legacy-cron:1'`, + ) + .get().revision, + 1, + ); + assert.equal( + client + .prepare( + `SELECT count(*) AS count FROM "QingLong3LocalSecretEnvelopes" WHERE "secret_name" = 'legacy-db-env-bbbbbbbbbbbbbbbb'`, + ) + .get().count, + 0, + ); + client.close(); +}); + +test('fails closed on occupied Secret without advancing Task or Trigger heads', async (t) => { + const databasePath = await preparedDatabase(t); + const client = new DatabaseSync(databasePath); + client + .prepare( + `INSERT INTO "QingLong3LocalSecretEnvelopes" ("project_id", "secret_name", "version", "mutation_id", "key_id", "algorithm", "nonce", "ciphertext", "auth_tag", "created_at_ms") VALUES ('default', 'legacy-db-env-bbbbbbbbbbbbbbbb', 1, '11111111-2222-4333-8444-555555555555', 'other-key', 'aes-256-gcm', ?, ?, ?, 150)`, + ) + .run(Buffer.alloc(12), Buffer.from('occupied'), Buffer.alloc(16)); + client.close(); + const database = await openLocalSqliteSecretConfigApplicationDatabase({ + databasePath, + profile: 'edge', + }); + await assert.rejects( + database.publisher.publish(applicationCommand()), + LocalSecretConfigApplicationConflictError, + ); + await database.close(); + const inspected = new DatabaseSync(databasePath, { readOnly: true }); + assert.equal( + inspected + .prepare( + `SELECT "current_revision" AS revision FROM "QingLong3TaskDefinitions" WHERE "task_id" = 'legacy-cron:1'`, + ) + .get().revision, + 1, + ); + assert.equal( + inspected + .prepare( + `SELECT count(*) AS count FROM "QingLong3SecretConfigApplications"`, + ) + .get().count, + 0, + ); + inspected.close(); +}); + +test('rejects incomplete Trigger provenance and rolls the streamed transaction back', async (t) => { + const databasePath = await preparedDatabase(t); + const client = new DatabaseSync(databasePath); + client.prepare(`DELETE FROM "QingLong3LegacyAdoptionTriggers"`).run(); + client.close(); + const database = await openLocalSqliteSecretConfigApplicationDatabase({ + databasePath, + profile: 'edge', + }); + await assert.rejects( + database.publisher.publish(applicationCommand()), + LocalSecretConfigApplicationConflictError, + ); + await database.close(); + const inspected = new DatabaseSync(databasePath, { readOnly: true }); + assert.equal( + inspected + .prepare( + `SELECT "current_revision" AS revision FROM "QingLong3TaskDefinitions" WHERE "task_id" = 'legacy-cron:1'`, + ) + .get().revision, + 1, + ); + assert.equal( + inspected + .prepare( + `SELECT count(*) AS count FROM "QingLong3SecretConfigApplicationTasks"`, + ) + .get().count, + 0, + ); + inspected.close(); +}); + +test('rejects replay after the durable Trigger schedule drifts', async (t) => { + const databasePath = await preparedDatabase(t); + const database = await openLocalSqliteSecretConfigApplicationDatabase({ + databasePath, + profile: 'edge', + }); + const command = applicationCommand(); + await database.publisher.publish(command); + await database.close(); + const client = new DatabaseSync(databasePath); + client + .prepare( + `UPDATE "QingLong3LocalTriggerSchedules" SET "trigger_revision" = 1 WHERE "trigger_id" = 'legacy-cron:1:cron:1'`, + ) + .run(); + client.close(); + const reopened = await openLocalSqliteSecretConfigApplicationDatabase({ + databasePath, + profile: 'edge', + }); + await assert.rejects( + reopened.publisher.publish(command), + LocalSecretConfigApplicationConflictError, + ); + await reopened.close(); +}); + +test('rejects empty applications and Secret timestamps outside the application instant', async (t) => { + const databasePath = await preparedDatabase(t); + const database = await openLocalSqliteSecretConfigApplicationDatabase({ + databasePath, + profile: 'edge', + }); + assert.throws( + () => database.publisher.publish({ ...applicationCommand(), secrets: [] }), + LocalSecretConfigApplicationConflictError, + ); + const command = applicationCommand(); + command.secrets[0].envelope.createdAtMs = command.appliedAtMs - 1; + assert.throws( + () => database.publisher.publish(command), + LocalSecretConfigApplicationConflictError, + ); + await database.close(); +}); diff --git a/scripts/ql3-local-image-audit.cjs b/scripts/ql3-local-image-audit.cjs index b251b448..56e75056 100644 --- a/scripts/ql3-local-image-audit.cjs +++ b/scripts/ql3-local-image-audit.cjs @@ -262,8 +262,8 @@ function auditDockerfile(contents, findings) { !contents.includes('io.qinglong.profile="edge,standalone"') || !contents.includes('io.qinglong.local.application-config="2,3,4"') || !contents.includes('io.qinglong.local.sqlite-contract-min="51"') || - !contents.includes('io.qinglong.local.sqlite-contract-max="51"') || - !contents.includes('io.qinglong.local.sqlite-write-contract="51"') || + !contents.includes('io.qinglong.local.sqlite-contract-max="52"') || + !contents.includes('io.qinglong.local.sqlite-write-contract="52"') || !contents.includes('io.qinglong.local.compose-selection="1"') ) { addFinding(findings, 'RUNTIME_IDENTITY_OR_LABEL_DRIFT'); @@ -285,7 +285,7 @@ function auditWorkflow(contents, findings) { 'pnpm audit:local-image:ql3', 'docker build', '--file deploy/containers/ql3-local-application/Dockerfile', - 'EXPECTED: ${{ matrix.image_arch }} 65532:65532 2,3,4 51 51 51 1', + 'EXPECTED: ${{ matrix.image_arch }} 65532:65532 2,3,4 51 52 52 1', 'actual="$(docker image inspect --format \'{{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.local.application-config"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-min"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-max"}} {{index .Config.Labels "io.qinglong.local.sqlite-write-contract"}} {{index .Config.Labels "io.qinglong.local.compose-selection"}}\' "${IMAGE}")"', 'io.qinglong.local.application-config', 'io.qinglong.local.sqlite-contract-min', diff --git a/test/back/ql3LocalImageAudit.test.cjs b/test/back/ql3LocalImageAudit.test.cjs index 26e1b97c..af5422c0 100644 --- a/test/back/ql3LocalImageAudit.test.cjs +++ b/test/back/ql3LocalImageAudit.test.cjs @@ -188,7 +188,7 @@ test('rejects removal of the SQLite rollout compatibility labels', () => { const dockerfilePath = path.join(current.target, 'Dockerfile'); const dockerfile = fs .readFileSync(dockerfilePath, 'utf8') - .replace(' io.qinglong.local.sqlite-write-contract="51" \\\n', ''); + .replace(' io.qinglong.local.sqlite-write-contract="52" \\\n', ''); fs.writeFileSync(dockerfilePath, dockerfile); const report = auditLocalImageContract(current.root); assert.equal(report.compatible, false); @@ -266,7 +266,7 @@ test('rejects a stale SQLite contract expectation in the native image CI gate', const workflow = fs .readFileSync(workflowPath, 'utf8') .replace( - 'EXPECTED: ${{ matrix.image_arch }} 65532:65532 2,3,4 51 51 51 1', + 'EXPECTED: ${{ matrix.image_arch }} 65532:65532 2,3,4 51 52 52 1', 'EXPECTED: ${{ matrix.image_arch }} 65532:65532 2,3,4 50 50 50 1', ); fs.writeFileSync(workflowPath, workflow); diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 6320f4b4..3304959e 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -79,7 +79,7 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( }, { directory: 'packages/ql3-local-sqlite/src/migrations', - directSourceFiles: 103, + directSourceFiles: 105, reviewKind: 'ordered_ledger', }, ], @@ -540,10 +540,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localSqlite.rootSourceFileRoles, }, { - sourceFiles: 206, + sourceFiles: 209, rootSourceFiles: 1, rootSourceLines: 31, - nestedSourceFiles: 205, + nestedSourceFiles: 208, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, );