diff --git a/.github/workflows/provider-promotion-canary.yml b/.github/workflows/provider-promotion-canary.yml new file mode 100644 index 00000000..c6211961 --- /dev/null +++ b/.github/workflows/provider-promotion-canary.yml @@ -0,0 +1,103 @@ +name: Provider Promotion Canary + +on: + workflow_dispatch: + inputs: + mode: + description: "Read-only verification mode" + required: true + type: choice + options: + - canary + - revocation + - kill-switch + node_a: + description: "First production AI Platform HTTPS base URL" + required: true + node_b: + description: "Second production AI Platform HTTPS base URL" + required: true + expected_release_id: + description: "Expected immutable release id for canary" + required: false + expected_receipt_id: + description: "Expected signed receipt id for canary" + required: false + expected_key_id: + description: "Expected receipt signing key id for canary" + required: false + expected_image_digest: + description: "Expected immutable AI Platform image digest for canary" + required: false + propagation_deadline_seconds: + description: "Maximum revocation or kill-switch propagation SLA" + required: false + default: "300" + +permissions: + contents: read + +concurrency: + group: provider-promotion-control + cancel-in-progress: false + +jobs: + verify: + runs-on: [self-hosted, yuqei-smoke] + environment: production-provider-promotion-control + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + clean: true + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Verify every production node without writes + shell: bash + env: + SERVICE_KEY: ${{ secrets.AI_PLATFORM_PRODUCTION_SERVICE_KEY }} + MODE: ${{ inputs.mode }} + NODE_A: ${{ inputs.node_a }} + NODE_B: ${{ inputs.node_b }} + EXPECTED_RELEASE_ID: ${{ inputs.expected_release_id }} + EXPECTED_RECEIPT_ID: ${{ inputs.expected_receipt_id }} + EXPECTED_KEY_ID: ${{ inputs.expected_key_id }} + EXPECTED_IMAGE_DIGEST: ${{ inputs.expected_image_digest }} + PROPAGATION_DEADLINE_SECONDS: ${{ inputs.propagation_deadline_seconds }} + run: | + set -euo pipefail + umask 077 + test -n "$SERVICE_KEY" + mkdir -p .promotion-control-private promotion-control-output + printf '%s' "$SERVICE_KEY" > .promotion-control-private/service-key + args=( + --mode "$MODE" + --node "$NODE_A" + --node "$NODE_B" + --service-key-file .promotion-control-private/service-key + ) + if [ "$MODE" = "canary" ]; then + args+=( + --expected-release-id "$EXPECTED_RELEASE_ID" + --expected-receipt-id "$EXPECTED_RECEIPT_ID" + --expected-key-id "$EXPECTED_KEY_ID" + --expected-image-digest "$EXPECTED_IMAGE_DIGEST" + ) + else + args+=(--deadline-seconds "$PROPAGATION_DEADLINE_SECONDS") + fi + python yuqei-infra/scripts/verify-provider-promotion-nodes.py "${args[@]}" \ + | tee promotion-control-output/provider-promotion-control-summary.json + - name: Upload read-only verification summary + if: always() + uses: actions/upload-artifact@v4 + with: + name: provider-promotion-control-summary + if-no-files-found: warn + retention-days: 30 + path: promotion-control-output/provider-promotion-control-summary.json + - name: Remove service credential + if: always() + shell: bash + run: rm -f .promotion-control-private/service-key diff --git a/.github/workflows/provider-promotion.yml b/.github/workflows/provider-promotion.yml new file mode 100644 index 00000000..891e1619 --- /dev/null +++ b/.github/workflows/provider-promotion.yml @@ -0,0 +1,199 @@ +name: Protected Provider Promotion + +on: + workflow_dispatch: + inputs: + phase: + description: "Collect technical evidence or finalize a legally signed package" + required: true + type: choice + options: + - collect + - finalize + shared_staging_api_base: + description: "Authenticated shared-staging AI Platform URL" + required: true + default: "https://staging.example.invalid/ai" + tenant_id: + description: "Acceptance tenant" + required: true + default: "default" + technical_run_id: + description: "Collect workflow run id (required for finalize)" + required: false + default: "" + legal_evidence_host: + description: "Controlled shared evidence hostname (required for finalize)" + required: false + default: "" + receipt_key_id: + description: "Trusted receipt key id (required for finalize)" + required: false + default: "" + target_release_id: + description: "Immutable target release id (required for finalize)" + required: false + default: "" + target_image_digest: + description: "Immutable target AI Platform image digest (required for finalize)" + required: false + default: "" + +permissions: + contents: read + actions: read + +concurrency: + group: provider-promotion-${{ inputs.tenant_id }} + cancel-in-progress: false + +jobs: + collect: + if: inputs.phase == 'collect' + runs-on: [self-hosted, yuqei-smoke] + environment: shared-staging-provider-promotion + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + clean: true + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install locked AI Platform package + shell: bash + run: python -m pip install ./yuqei-ai-platform/services/ai-platform-api + - name: Collect deterministic legal review package + shell: bash + env: + SERVICE_KEY: ${{ secrets.AI_PLATFORM_SERVICE_KEY }} + SHARED_STAGING_API_BASE: ${{ inputs.shared_staging_api_base }} + TENANT_ID: ${{ inputs.tenant_id }} + run: | + set -euo pipefail + umask 077 + test -n "$SERVICE_KEY" + mkdir -p .promotion-private promotion-output + printf '%s' "$SERVICE_KEY" > .promotion-private/service-key + python yuqei-infra/scripts/run-ai-provider-acceptance.py \ + --mode collect \ + --base-url "$SHARED_STAGING_API_BASE" \ + --tenant-id "$TENANT_ID" \ + --service-key-file .promotion-private/service-key \ + --artifact-dir promotion-output \ + --review-package-file promotion-output/provider-technical-review-package.json + - name: Upload allowlisted review artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: provider-technical-review + if-no-files-found: error + retention-days: 14 + path: | + promotion-output/provider-technical-review-package.json + promotion-output/ai-provider-acceptance-summary.json + promotion-output/ai-provider-acceptance-summary.md + - name: Remove private inputs + if: always() + shell: bash + run: rm -f .promotion-private/service-key + + finalize: + if: inputs.phase == 'finalize' + runs-on: [self-hosted, yuqei-smoke] + environment: shared-staging-provider-promotion + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + clean: true + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Validate protected inputs + shell: bash + env: + TECHNICAL_RUN_ID: ${{ inputs.technical_run_id }} + LEGAL_EVIDENCE_HOST: ${{ inputs.legal_evidence_host }} + RECEIPT_KEY_ID: ${{ inputs.receipt_key_id }} + TARGET_RELEASE_ID: ${{ inputs.target_release_id }} + TARGET_IMAGE_DIGEST: ${{ inputs.target_image_digest }} + run: | + set -euo pipefail + test -n "$TECHNICAL_RUN_ID" + test -n "$LEGAL_EVIDENCE_HOST" + test -n "$RECEIPT_KEY_ID" + test -n "$TARGET_RELEASE_ID" + test -n "$TARGET_IMAGE_DIGEST" + - name: Download exact technical package + uses: actions/download-artifact@v4 + with: + name: provider-technical-review + run-id: ${{ inputs.technical_run_id }} + github-token: ${{ github.token }} + path: technical-input + - name: Install locked AI Platform package + shell: bash + run: python -m pip install ./yuqei-ai-platform/services/ai-platform-api + - name: Finalize signed promotion without rerunning Provider + shell: bash + env: + SERVICE_KEY: ${{ secrets.AI_PLATFORM_SERVICE_KEY }} + LEGAL_APPROVAL_B64: ${{ secrets.PROVIDER_LEGAL_APPROVAL_JSON_B64 }} + LEGAL_KEYRING_B64: ${{ secrets.PROVIDER_LEGAL_KEYRING_JSON_B64 }} + RECEIPT_PRIVATE_KEY_B64: ${{ secrets.PROVIDER_RECEIPT_PRIVATE_KEY_PEM_B64 }} + SHARED_STAGING_API_BASE: ${{ inputs.shared_staging_api_base }} + TENANT_ID: ${{ inputs.tenant_id }} + LEGAL_EVIDENCE_HOST: ${{ inputs.legal_evidence_host }} + RECEIPT_KEY_ID: ${{ inputs.receipt_key_id }} + TARGET_RELEASE_ID: ${{ inputs.target_release_id }} + TARGET_IMAGE_DIGEST: ${{ inputs.target_image_digest }} + run: | + set -euo pipefail + umask 077 + test -n "$SERVICE_KEY" + test -n "$LEGAL_APPROVAL_B64" + test -n "$LEGAL_KEYRING_B64" + test -n "$RECEIPT_PRIVATE_KEY_B64" + mkdir -p .promotion-private promotion-output + printf '%s' "$SERVICE_KEY" > .promotion-private/service-key + printf '%s' "$LEGAL_APPROVAL_B64" | base64 --decode > .promotion-private/legal-approval.json + printf '%s' "$LEGAL_KEYRING_B64" | base64 --decode > .promotion-private/legal-keyring.json + printf '%s' "$RECEIPT_PRIVATE_KEY_B64" | base64 --decode > .promotion-private/receipt-private-key.pem + python yuqei-infra/scripts/run-ai-provider-acceptance.py \ + --mode finalize \ + --base-url "$SHARED_STAGING_API_BASE" \ + --tenant-id "$TENANT_ID" \ + --service-key-file .promotion-private/service-key \ + --artifact-dir promotion-output \ + --review-package-file technical-input/provider-technical-review-package.json \ + --legal-approval-file .promotion-private/legal-approval.json \ + --legal-keyring-file .promotion-private/legal-keyring.json \ + --legal-evidence-allowed-host "$LEGAL_EVIDENCE_HOST" \ + --receipt-private-key-file .promotion-private/receipt-private-key.pem \ + --receipt-key-id "$RECEIPT_KEY_ID" \ + --target-release-id "$TARGET_RELEASE_ID" \ + --target-image-digest "$TARGET_IMAGE_DIGEST" \ + --receipt-output promotion-output/provider-acceptance-receipt.json \ + --promotion-bundle-output promotion-output/provider-promotion-bundle.json + - name: Upload allowlisted promotion artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: provider-promotion-bundle + if-no-files-found: error + retention-days: 30 + path: | + promotion-output/provider-acceptance-receipt.json + promotion-output/provider-promotion-bundle.json + promotion-output/ai-provider-acceptance-summary.json + promotion-output/ai-provider-acceptance-summary.md + - name: Remove private inputs + if: always() + shell: bash + run: | + rm -f \ + .promotion-private/service-key \ + .promotion-private/legal-approval.json \ + .promotion-private/legal-keyring.json \ + .promotion-private/receipt-private-key.pem diff --git a/.secrets.baseline b/.secrets.baseline index 0dcca99a..64eb4e9c 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -288,14 +288,6 @@ "is_secret": false, "audit_reason": "Explicit wrong-password fixture used by a negative test." }, - { - "type": "Secret Keyword", - "filename": "yuqei-contract-platform/services/contract-api/tests/test_identity_management.py", - "hashed_secret": "2585823c3e56d65632eff0c1eb7f8fffa0736cb3", - "line_number": 189, - "is_secret": false, - "audit_reason": "Synthetic invite password used by an identity test." - }, { "type": "Secret Keyword", "filename": "yuqei-contract-platform/services/contract-api/tests/test_identity_management.py", diff --git a/package-lock.json b/package-lock.json index dd07aa38..5e4d73d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,16 @@ "playwright": "^1.61.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "license": "MIT", @@ -23,144 +33,476 @@ "node": ">=18" } }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, - "node_modules/@next/env": { - "version": "16.2.9", - "license": "MIT" - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz", - "integrity": "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==", + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz", - "integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz", - "integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz", - "integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz", - "integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz", - "integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz", - "integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==", - "cpu": [ - "arm64" + "ia32" ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" ], "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.9", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@swc/helpers": { @@ -254,73 +596,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/nanoid": { - "version": "3.3.15", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/next": { - "version": "16.2.9", - "license": "MIT", - "dependencies": { - "@next/env": "16.2.9", - "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.9.19", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": ">=20.9.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.9", - "@next/swc-darwin-x64": "16.2.9", - "@next/swc-linux-arm64-gnu": "16.2.9", - "@next/swc-linux-arm64-musl": "16.2.9", - "@next/swc-linux-x64-gnu": "16.2.9", - "@next/swc-linux-x64-musl": "16.2.9", - "@next/swc-win32-arm64-msvc": "16.2.9", - "@next/swc-win32-x64-msvc": "16.2.9", - "sharp": "^0.34.5" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.51.1", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, "node_modules/picocolors": { "version": "1.1.1", "license": "ISC" @@ -353,32 +628,6 @@ "node": ">=18" } }, - "node_modules/postcss": { - "version": "8.5.15", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/react": { "version": "19.2.7", "license": "MIT", @@ -411,49 +660,6 @@ "node": ">=10" } }, - "node_modules/sharp": { - "version": "0.34.5", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "license": "BSD-3-Clause", @@ -509,7 +715,7 @@ "dependencies": { "@yuqei/design-system": "0.1.0", "lucide-react": "1.21.0", - "next": "16.2.9", + "next": "16.3.0", "react": "19.2.7", "react-dom": "19.2.7" }, @@ -520,6 +726,308 @@ "typescript": "5.9.3" } }, + "yuqei-contract-platform/apps/contract-web/node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "yuqei-contract-platform/apps/contract-web/node_modules/@next/env": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", + "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", + "license": "MIT" + }, + "yuqei-contract-platform/apps/contract-web/node_modules/@next/swc-darwin-arm64": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", + "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "yuqei-contract-platform/apps/contract-web/node_modules/@next/swc-darwin-x64": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz", + "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "yuqei-contract-platform/apps/contract-web/node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz", + "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "yuqei-contract-platform/apps/contract-web/node_modules/@next/swc-linux-arm64-musl": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz", + "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "yuqei-contract-platform/apps/contract-web/node_modules/@next/swc-linux-x64-gnu": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz", + "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "yuqei-contract-platform/apps/contract-web/node_modules/@next/swc-linux-x64-musl": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz", + "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "yuqei-contract-platform/apps/contract-web/node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz", + "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "yuqei-contract-platform/apps/contract-web/node_modules/@next/swc-win32-x64-msvc": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz", + "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "yuqei-contract-platform/apps/contract-web/node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "yuqei-contract-platform/apps/contract-web/node_modules/next": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", + "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", + "license": "MIT", + "dependencies": { + "@next/env": "16.3.0", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.3.0", + "@next/swc-darwin-x64": "16.3.0", + "@next/swc-linux-arm64-gnu": "16.3.0", + "@next/swc-linux-arm64-musl": "16.3.0", + "@next/swc-linux-x64-gnu": "16.3.0", + "@next/swc-linux-x64-musl": "16.3.0", + "@next/swc-win32-arm64-msvc": "16.3.0", + "@next/swc-win32-x64-msvc": "16.3.0", + "sharp": "^0.35.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "yuqei-contract-platform/apps/contract-web/node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "yuqei-contract-platform/apps/contract-web/node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "yuqei-contract-platform/packages/design-system": { "name": "@yuqei/design-system", "version": "0.1.0" diff --git a/package.json b/package.json index 898f2106..68293987 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "templates:sweep": "node yuqei-infra/scripts/verify-template-draft-sweep.cjs" }, "overrides": { - "postcss": "8.5.15" + "postcss": "8.5.26" }, "devDependencies": { "playwright": "^1.61.0" diff --git a/project-docs/V2-privileged-identity-operations-governance-2026-08-02.md b/project-docs/V2-privileged-identity-operations-governance-2026-08-02.md index ef17dfb5..ef5207db 100644 --- a/project-docs/V2-privileged-identity-operations-governance-2026-08-02.md +++ b/project-docs/V2-privileged-identity-operations-governance-2026-08-02.md @@ -1071,3 +1071,17 @@ P0 阻断项包括:durable grant/revocation 与跨节点一致性缺失、检 - 当前可锁定资源只有合同和审查任务;审计动作锁定合同锚点;模板尚无稳定的租户/版本资源行,因此固定返回 `support_access_resource_not_lockable`。任何缺失、漂移、锁超时、策略损坏或依赖错误均 fail closed,且不返回资源内容、token、session 或 grant。 - 事务要求无 `new/dirty/deleted` 状态变化,policy/revocation/operation/approval/identity/event/outbox 快照保持不变。真实 support access、身份执行器、共享会话、impersonation 与破坏性身份写接口继续关闭,No-Go 不变。 - 验收证据:enforcement 与 support access 持久化专项 `5/5`,计划/特权身份/投递/反滥用组合 `59/59`,真实 PostgreSQL 隔离专项 `3/3`,Python compileall、`git diff --check` 和危险路由扫描通过;随机 schema 残留为 `0`。 + +闭环范围仅限 deny-only validation 和可锁资源锚点;真实资源读取、强认证与会话/设备绑定、跨节点撤销传播、逐请求不可变审计/告警、隐私与法律门禁仍是后续独立阶段。任何具名法务签批必须由实际法务责任人针对正式版本留下可核验证据,AI 或项目人员不得代签、推定完成或伪造姓名、日期、签名、批准记录及生效版本。 + +## 36. support access 安全控制账本与逐请求审计复核(代码验收,发布 No-Go,2026-08-10) + +迁移 `20260809_0045` 新增 fail-closed singleton control、追加式 control events、脱敏 request audits 和 audit outbox。默认 kill switch、外部 IdP 和隐私法律门禁全部阻断,`executor_enabled=false` 同时由 DTO、ORM 和数据库 CHECK 约束。控制账本和请求账本在 SQLite/PostgreSQL 均禁止 UPDATE/DELETE。 + +- 每次内部验证先锁 control,再按固定顺序锁 policy、operation、approval/identity 和可锁资源;资源只在 tenant/deleted 条件及行锁内生成 anchor hash,不返回资源内容。 +- policy 撤销在同一事务递增 revocation epoch 并追加事件;请求携带的 observed epoch 必须与数据库一致。配置的 5 秒 SLA 仅是 fail-closed 策略上限,尚未在多服务节点和真实缓存/消息基础设施上测量。 +- IdP assertion、MFA 类型、step-up 时间、session/device hash 和 audience 是受信内部认证边界输入,不是面向客户端的凭据,也不证明外部 IdP、WebAuthn、硬件 OTP 或会话生命周期已经接通。 +- 每个 deny-only 决策在同一事务先 flush 不可变 audit,再追加 outbox;PostgreSQL 即时外键确保不存在孤立 outbox,失败时整体回滚。审计只保存哈希、门禁状态、错误码、epoch 和 trace,不保存资源内容、token、session 或原始身份字段。 +- SQLite 专项 `24/24`、真实 PostgreSQL `3/3`、全量 Contract API `281 passed, 3 skipped`,其中 3 个 PostgreSQL opt-in skip 已由真实专项补齐;运行态 OpenAPI 中 support access API `0`、危险身份写路由 `0`。 + +发布仍为 **No-Go**。需要外部完成的门禁是:真实 IdP/MFA 与会话绑定集成、跨至少两个节点的撤销传播 SLA 测量、kill switch 控制面/权限/告警演练、真实资源授权后读取,以及具名隐私和法务批准。不得根据内部 assertion 字段、控制表或自动化测试推定这些门禁已经通过。 diff --git a/project-docs/V2-privileged-identity-security-review-2026-08-09.md b/project-docs/V2-privileged-identity-security-review-2026-08-09.md index a22bf33a..23cb8aa8 100644 --- a/project-docs/V2-privileged-identity-security-review-2026-08-09.md +++ b/project-docs/V2-privileged-identity-security-review-2026-08-09.md @@ -14,6 +14,8 @@ ## 2. P0 发布阻断项 +状态说明:本节保留首次评审时的历史阻断基线,不随代码整改直接改写。后续关闭或部分关闭状态以第 6、7 节复核为准;基础设施或 validation-only 验收不得被解释为真实 support access 发布批准。 + 1. **无运行时可信授权源**:当前策略材料是纯内存输入,没有持久化 grant、撤销记录、单一真相版本或跨实例一致性协议。任何接入都可能读取陈旧授权。 2. **TOCTOU 未闭合**:validation-only 检查与未来资源访问不在同一受控事务/策略执行点;检查后账号、工作空间、scope、revision 或撤销状态仍可能变化。 3. **可信时间未定义**:`checked_at` 由内部调用方传入,尚未绑定数据库时间或受保护的服务端时钟;不能作为真实自动到期依据。 @@ -59,8 +61,22 @@ 发布结论仍为 **No-Go**。尚未关闭的 P0 包括:授权检查与资源读取 TOCTOU、统一 deny-by-default enforcement point、强认证与会话/设备绑定、撤销跨节点传播 SLA 与 kill switch、逐请求不可变审计/告警,以及隐私与法律门禁。真实 support access、身份执行器和共享会话不得启用。 -## 7. 锁内 deny-only enforcement 复核(2026-08-09) +## 7. 第二次整改复核:锁内 deny-only enforcement(2026-08-09) + +第 25 阶段已完成统一 deny-only validation enforcement point、固定动作到 scope/resource 的服务端映射、PostgreSQL 锁后 `clock_timestamp()` 复核,以及合同/审查资源锚点的同事务锁定。所有决策仍固定 `enforcement_status=denied`、`access_granted=false`、`executor_enabled=false`,未增加 API、资源内容读取、token/session 或 impersonation。 + +历史 P0 状态更新如下:第 1 项 durable policy/revocation 基础和第 3 项 validation 使用的数据库可信时间已关闭;第 2 项 TOCTOU 与第 7 项统一 enforcement point 只在 deny-only validation 和当前可锁资源锚点范围内部分关闭,尚未覆盖真实资源读取与使用;第 4 项强认证/会话绑定、第 5 项跨节点撤销传播与 kill switch、第 6 项逐请求不可变审计/告警、第 8 项隐私与法律门禁仍未关闭。 新增的内部 enforcement contract 只允许固定资源动作,并在 policy、审批/主体、撤销记录和可锁定资源锚点全部取得行锁后刷新状态。即使 `policy_satisfied=true`,决策仍固定 `enforcement_status=denied`、`access_granted=false`、`executor_enabled=false`;模板等缺乏稳定资源锚点的动作直接拒绝。该实现未增加路由、transport、凭据、session 或 impersonation。 本阶段已完成验证,但不能宣称完整 check-use TOCTOU 已关闭;真实资源读取尚未接入同一事务,强认证/MFA、设备/会话绑定、撤销传播 SLA、逐请求审计/告警及隐私/法律门禁仍未完成。发布结论继续为 **No-Go**。 + +具名法务批准必须来自实际法务责任人对正式版本的可核验证据;AI、研发、测试或运营不得代签、推定、补写或伪造姓名、日期、签名、批准记录及生效版本。没有真实批准证据时,隐私与法律门禁必须保持未完成。 + +## 8. 第三次整改复核:安全控制账本与逐请求审计(2026-08-10) + +`20260809_0045` 已补齐 deny-only 内部路径的 fail-closed control、revocation epoch、默认启用的 kill switch、受信认证断言契约、资源锁内 anchor、逐请求不可变 audit 和同事务 outbox。PostgreSQL 并发启动只创建一个 baseline control event,audit 在 outbox 前 flush 以满足即时外键,两者仍在同一事务中原子提交或回滚。 + +历史 P0 状态再次更新:第 5 项的 durable revocation epoch 和默认 kill switch、第 6 项的逐请求不可变审计/outbox 已在内部 deny-only 范围部分关闭;第 2、4、5、6、7、8 项仍不能整体关闭。原因是当前没有真实资源内容读取、外部 IdP/WebAuthn/硬件 OTP/session 集成、多节点传播测量、kill switch 运维控制面与告警演练、审计消费/告警链路以及具名隐私法律批准。 + +发布结论继续为 **No-Go**。运行态不存在 support access API,危险身份写路由为 `0`,所有决策和数据库约束保持 `access_granted=false`、`executor_enabled=false`。`revocation_sla_seconds<=5` 是待验证的目标,不是已通过的跨节点 SLA;内部请求中的 assertion/hash 也不是认证系统签发或验证证据。 diff --git a/project-docs/V2-product-audit-and-development-backlog-2026-08-01.md b/project-docs/V2-product-audit-and-development-backlog-2026-08-01.md index 96c40578..466e0bbf 100644 --- a/project-docs/V2-product-audit-and-development-backlog-2026-08-01.md +++ b/project-docs/V2-product-audit-and-development-backlog-2026-08-01.md @@ -301,7 +301,7 @@ legal_name: string | null ### 9.4 第二阶段已完成 -- **AI 生产发布门禁**:新增 `GET /api/v1/quality/release-readiness` 和可供 CI 调用的 `python -m yuqei_ai_platform_api.release_readiness_gate`。生产环境必须启用非占位 Provider、可解析 URL 与凭据、关闭 fallback、通过黄金资产和引用策略检查,并提供指定的真实 `acceptance_run_id`。离线黄金资产结果明确标记为不验证 Provider 输出。 +- **AI 生产发布门禁**:新增 `GET /api/v1/quality/release-readiness` 和可供 CI 调用的 `python -m yuqei_ai_platform_api.release_readiness_gate`。生产环境必须启用非占位 Provider、可解析 URL 与凭据、关闭 fallback、通过黄金资产和引用策略检查,并提供共享预发布持久化的完整 Provider 验收清单 `acceptance_run_id`。单条审计运行 ID、缺少 10+8 样本、10 张知识卡、资产哈希或结构化具名法务审批证据时均失败关闭。离线黄金资产结果明确标记为不验证 Provider 输出。 - **协议版本与同意留痕**:新增公开协议目录和受控同意记录查询接口。个人与组织注册必须逐项提交服务端目录中的当前精确版本;缺失、重复、额外或客户端自定义版本均被拒绝。 - **注册事务一致性**:工作空间、owner/admin、账户、凭据和四条同意记录在同一事务中创建,任一写入失败整体回滚。`20260801_0034` 新增 `identity_agreement_consents`,记录工作空间、用户、协议类型、版本、服务端同意时间、注册来源、客户端 IP 和 User-Agent。 - **注册前端闭环**:个人与组织模式均从同源协议目录加载必签项,逐项展示政策链接、精确版本和“草案未生效”状态;未全部勾选时提交禁用,模式切换清空已有同意。 @@ -319,7 +319,7 @@ legal_name: string | null ### 9.6 下一阶段优先项 -1. 将真实 Provider 验收扩展到审查、生成、条款推荐等完整黄金套件,并在共享预发布环境持久化通过的审计运行 ID;单场景隔离验收不得替代完整发布证据。 +1. 真实 Provider 验收固定覆盖实际调用模型的 10 条法律问答与 8 条合同审查,并在共享预发布环境持久化完整验收清单;合同生成与条款推荐继续由独立确定性产品黄金套件验证,不伪装为 Provider 调用。单场景或单条审计运行 ID 不得替代完整发布证据。 2. 完成四份政策的具名法务审核、正式版本号、生效时间、历史版本查询和再次同意策略。 3. 在现有全局账号兼容层上收口统一凭据、已验证邮箱归并、账号合并/拆分和成员关系恢复流程,避免仅凭相同邮箱长期承担跨空间身份绑定语义。 4. 补齐平台管理员多人治理、支持访问授权审计,以及个人套餐、配额、用量和数据退出能力。 @@ -724,7 +724,7 @@ P0 后续 backlog:建立 durable grant/revocation 与跨节点一致性;绑 明确非目标:没有 API、Web、token/session、impersonation、transport、真实 grant/revoke 或身份执行入口。No-Go 继续生效;下一 P0 应优先设计统一 deny-by-default enforcement point 和同一请求内的 TOCTOU 闭合,同时保留强认证、撤销传播、逐请求审计及隐私/法律门禁。 -### 9.44 第二十五阶段:deny-only enforcement point 与锁内资源复核(进行中,2026-08-09) +### 9.44 第二十五阶段:deny-only enforcement point 与锁内资源复核(已验收,2026-08-09) 1. **P0-ENFORCEMENT-1 固定动作契约(完成)**:动作只能来自闭合集合,由服务端映射到 scope/resource;未知动作不能构造请求。 2. **P0-ENFORCEMENT-2 锁内复核(完成)**:内部 repository 在同一事务中按固定顺序锁 policy、operation、审批主体、support principal、撤销记录和合同/审查资源锚点,锁后刷新并重新使用可信数据库时间检查。 @@ -732,3 +732,78 @@ P0 后续 backlog:建立 durable grant/revocation 与跨节点一致性;绑 4. **P0-ENFORCEMENT-4 回归门禁(完成)**:enforcement 与 support access 持久化专项 `5/5`,计划/特权身份/投递/反滥用组合 `59/59`,真实 PostgreSQL 隔离专项 `3/3`,compileall、diff-check、危险路由扫描通过,随机 schema 残留 `0`。 明确非目标:不增加 enforcement/support-access/execute/grant/revoke API,不读取或返回合同内容,不创建 token/session,不启用身份执行器。 + +闭环边界:本阶段只验收 deny-only validation 契约和锁内资源锚点复核,不构成真实资源访问、support access 发布或身份执行批准。后续仍须依次完成强认证与会话/设备绑定、跨节点撤销传播与 kill switch、逐请求不可变审计/告警,以及隐私和法律门禁。具名法务签批只能由实际承担责任的法务人员基于正式版本和可核验证据作出;AI、研发、测试或运营人员不得代签、推定已签批,或伪造法务姓名、签批日期、签名、批准记录和生效版本。缺少真实签批证据时必须继续标记为未完成和发布阻断。 + +### 9.45 第二十六阶段:support access 安全控制账本与逐请求审计(代码验收,外部门禁阻断,2026-08-10) + +1. **P0-SUPPORT-SEC-1 全局 fail-closed 控制(完成)**:迁移 `20260809_0045` 增加 singleton control 和追加式控制事件,默认 `kill_switch_engaged=true`、`external_idp_ready=false`、`privacy_legal_gate_approved=false`、`executor_enabled=false`;数据库 CHECK 禁止打开执行器。 +2. **P0-SUPPORT-SEC-2 撤销 epoch 与并发启动(完成)**:policy 撤销在同一事务递增 control version/revocation epoch 并追加不可变事件;PostgreSQL 并发 bootstrap 通过 `INSERT ... RETURNING` 只产生一条 baseline 事件。`revocation_sla_seconds<=5` 是策略目标,不是跨节点实测结论。 +3. **P0-SUPPORT-SEC-3 强认证输入契约(内部完成,外部未验收)**:内部请求要求受信认证边界传入 IdP assertion、WebAuthn/硬件 OTP 类型、step-up 时间、session/device hash、固定 audience 和 revocation epoch。当前没有外部 IdP/WebAuthn/session API 集成,调用方布尔值或 hash 不能被解释为真实 MFA 已完成。 +4. **P0-SUPPORT-SEC-4 TOCTOU 与逐请求证据(完成于 deny-only 范围)**:合同/审查资源使用 tenant/deleted 过滤并在 `FOR UPDATE` 后生成脱敏 anchor hash;同一事务先追加不可变 request audit,再追加 outbox,外键或 outbox 失败整体回滚。模板继续稳定返回 `support_access_resource_not_lockable`,任何结果仍固定 `access_granted=false`。 +5. **P0-SUPPORT-SEC-5 回归门禁(完成)**:SQLite support/persistence/migration `24/24`,真实 PostgreSQL 随机 schema `3/3`,全量 Contract API `281 passed, 3 skipped`;3 个 skip 均为 opt-in PostgreSQL 专项且已单独真实运行通过。Alembic 单一 head `20260809_0045`,随机 schema 残留 `0`,运行态 support access API `0`、危险身份写路由 `0`。 + +发布结论仍为 **No-Go**:尚缺真实外部 IdP/MFA 与会话生命周期集成、两个以上真实服务节点的撤销传播时延测量、kill switch 运维控制面和告警演练、真实资源内容的授权后读取路径,以及具名隐私/法务批准。身份执行器和 support access API 继续关闭。 + +### 9.46 第二十七阶段:动态 Provider 故障门禁与产品黄金套件分界(技术验收,外部审批阻断,2026-08-10) + +1. **P0-AI-PROVIDER-1 超时与降级(完成)**:真实 loopback HTTP 服务注入延迟,验证稳定超时错误和脱敏审计;staging 仅在显式开关下允许 fallback,production 即使误配 fallback flag、缺密钥或返回非法结构也不得 fallback。 +2. **P0-AI-PROVIDER-2 验收边界(完成)**:真实 Provider acceptance 只覆盖实际调用 Provider 的 10 条 Legal QA 和 8 条合同审查,不再把确定性产品生成伪装为 `/legal/qa`。 +3. **P0-AI-GOLDEN-3 产品黄金套件(完成)**:独立脚本直接调用 Contract API 模板渲染和条款推荐逻辑,23 个合同生成与 8 个条款推荐共 `31/31` 技术通过;缺失必填业务值保留为明确占位符。 +4. **P0-AI-LEGAL-4 法务与预发布证据(阻断)**:10 张知识卡、10 条问答、8 条审查及 31 条产品黄金资产均未具备真实具名审批证据,自动化结果保持 `needs_legal_review`、`legal_approved=0`。当前本机没有共享预发布 Provider 凭据或已批准的运行证据,因此未生成、伪造或持久化 `acceptance_run_id`。 + +自动化证据:AI Platform `113/113`、Infra `32/32`、产品黄金门禁 `31/31` 技术通过;本地运行 AI Platform 使用 mock/disabled Provider,`provider_acceptance_verified=false`,不得作为真实联网验收。 + +### 9.47 第二十八阶段:Provider 整套验收清单与共享预发布持久化门禁(技术完成,外部法务审批阻断,2026-08-10) + +1. **P0-AI-MANIFEST-1 独立追加式验收清单(完成)**:新增 `provider_acceptance_runs`、Alembic `20260810_0023` 至 `20260810_0025`,持久化租户、共享预发布来源、Provider 配置、10 条问答、8 条审查、10 张知识卡、18 条逐样本审计绑定、两类资产哈希、具名法务审批时间、共享证据引用/哈希及具名法务 Ed25519 审批信封元数据。整份清单使用规范化 SHA-256 生成幂等 ID,表无 update/delete 接口。 +2. **P0-AI-MANIFEST-2 失败关闭发布门禁(完成)**:生产 release readiness 不再接受单条 `AiRunAudit`。每次检查重新核验租户、当前 Provider/模型、精确样本与知识卡集合、样本输入绑定、18 条无 fallback/错误且质量/引用达标的审计、资产哈希、清单哈希、具名审批人、带时区且晚于全部输出的审批时间及非占位共享证据。旧单审计 ID、缺字段、9/10、7/8、9/10、错绑、错 hash、跨租户和事后篡改均阻断。 +3. **P0-AI-MANIFEST-3 共享预发布受限登记(完成)**:只在 `environment=staging`、`repository_backend=sqlalchemy` 且启用服务鉴权时开放内部登记;通用 service key 之外还必须提供受信 keyring 中具名法务 Ed25519 审批信封,服务端重新读取共享证据并校验真实内容 hash,旧 HMAC 直接拒绝。`source_environment=shared_staging` 和 policy version 由服务端写入,客户端不能自报。重复证据幂等返回同一 ID,并处理并发唯一键竞争。 +4. **P0-AI-MANIFEST-4 法务审批边界(阻断)**:Provider acceptance CLI 默认仍只产出脱敏 `needs_legal_review` 证据且不登记 ID。只有显式提供覆盖 10+8+10 的结构化具名法务审批文件、Ed25519 签名和可信 keyring、共享证据 URI 与服务端重新计算的真实 SHA-256,且 18 条技术结果全部通过时,才调用共享预发布登记接口。当前未收到真实法务文件、共享 staging 真实运行或真实 `acceptance_run_id`,未生成或伪造任何外部证据。 +5. **P0-SUPPORT-TOCTOU-5 锁语义复核(完成于 deny-only 范围)**:治理锁计划完成与资源锁内观察分别由 `locks_acquired`、`resource_observed_under_lock` 表示;kill switch 拒绝时前者为 true、后者为 false,资源存在且全部安全门满足时两者均为 true。真实 PostgreSQL 随机隔离 schema 的事务/故障回滚/support access 三项 `3/3` 和 SQLite support access `7/7` 通过,schema 已清理;执行器、support access API 和真实资源授权后读取继续关闭。 + +自动化证据:AI Platform `149/149`、Infra `64/64`、产品黄金 `31/31` 技术通过;AI Platform 在全新 SQLite 验证 `0023` 至 `0025` 完整升级与回退,表、法务审批列、唯一约束和复合索引均存在。外部具名法务审批、真实共享预发布 Provider 全套运行、真实证据对象内容 hash、真实 `acceptance_run_id`、受保护 CI 签名收据和生产只读 canary 仍是发布阻断项。 + +### 9.48 第二十九阶段:共享预发布签名验收收据到生产只读验证(技术完成,真实证据阻断,2026-08-10) + +1. **P0-AI-PROMOTION-1 非对称签名收据 v2(完成)**:共享预发布在完整 aggregate manifest、18 条 audit binding、10 张知识卡和具名法务 Ed25519 审批再校验通过后,导出规范化 receipt v2 payload;CI/预发布侧使用 Ed25519 私钥签名。生产只持公钥 keyring,不能生成或伪造收据。收据覆盖 tenant、目标 production audience、不可变 release ID、目标 image digest、Provider/model/base URL/wire API/temperature/max tokens、已发布 Prompt 内容 hash、质量策略 hash、资产与清单 hash、法务证据及签发/生效/到期时间。 +2. **P0-AI-PROMOTION-2 生产离线只读验证(完成)**:生产 readiness 仅从只读 receipt/keyring 文件验证签名、部署 SHA-256 pin、租户、release、运行时指纹、有效期、最大 TTL、公钥生效期与撤销状态。有效收据可在 production acceptance/audit 空库下幂等复核;实现和 spy 回归均确认 `get_provider_acceptance_run()` 与 `get_ai_run_audit()` 调用为 `0`。缺失、未 pin、篡改、过期、未来签发、超长 TTL、未知/撤销 key、跨租户/release 或 runtime 漂移均失败关闭,且不得回退本地数据库。 +3. **P0-AI-PROMOTION-3 签发与部署隔离(完成)**:Provider acceptance CLI 新增 receipt 私钥、key ID、目标 release/image digest 与独立输出路径四参数成组校验,仅在取得真实 `acceptance_run_id` 后从 shared staging GET payload、严格校验绑定并原子写出独立 JSON;摘要只记录 `receipt_id` 与文件 SHA-256。生产 Compose 只读挂载 receipt/keyring;preflight 强制 release/image digest、文件 hash pin、TTL/clock skew,并拒绝法务私钥、签名私钥和 staging 数据库凭据。 +4. **P0-AI-PROMOTION-4 API 与身份边界(完成)**:仅增加 authenticated staging-only receipt payload GET;没有 receipt import/write、promotion POST、execute、support access 或身份写 API。身份执行器继续关闭,promotion 不引入生产状态消费写入;同一有效收据允许只读幂等复核,重放范围由 tenant、audience、release、runtime 与短有效期共同限制。 +5. **P0-AI-PROMOTION-5 外部门禁(阻断)**:本阶段没有伪造具名法务审批、真实 Provider 结果、真实 `acceptance_run_id`、签名私钥或生产收据。上线仍需具名法务完成 10 张知识卡和 18 条 Provider 结果审批,共享预发布运行并持久化真实 manifest,由受保护 CI 签发 receipt v2,运营维护公钥轮换/撤销 keyring 后再执行生产 preflight 与只读 readiness。 + +自动化证据:AI Platform 全量 `149/149`、promotion/registration 定向测试通过,Infra 全量 `64/64`、产品黄金 `31/31`;Ed25519 真实签名/验签、服务端 evidence bytes/hash、生产空 acceptance/audit 数据库、零 repository 回查、tamper、tenant/release/runtime/image digest、时间边界、双 key 轮换/撤销、无效收据不回退、只读挂载、SHA-256 pin 和敏感生产配置拒绝均已覆盖。Python 编译与 `git diff --check` 通过;本机无 Docker CLI,因此 Compose 使用静态回归验证,未声称执行了 `docker compose config`。 + +### 9.49 第三十阶段:具名法务签名与真实证据内容绑定(技术完成,真实法务签批阻断,2026-08-10) + +1. **P0-AI-LEGAL-1 两阶段职责分离(完成)**:`collect` 只运行 10 条法律问答和 8 条合同审查并生成确定性、脱敏技术包;`finalize` 只消费已收集的精确技术包、具名法务审批信封和 trusted keyring,不重新运行 Provider,也不允许 CI 代替法务作出审批决定。 +2. **P0-AI-LEGAL-2 Ed25519 具名审批(完成)**:审批 payload 绑定 tenant、approver、role、organization、Provider 配置、10+8+10 清单、18 条 audit binding、技术包 hash、证据 URI/hash、批准与到期时间;服务端验证受信公钥、key 有效期/撤销、签名和全部字段绑定。旧 HMAC 在受保护登记 API 明确返回 `422`。 +3. **P0-AI-LEGAL-3 真实内容 hash(完成)**:共享预发布服务端按 host allowlist、超时和大小上限读取 evidence bytes,并重新计算 SHA-256;只提交 URI 和调用方声明 hash 不能通过。18 条 audit 同时绑定精确 bundled sample question 与脱敏 audit snapshot hash。 +4. **P0-AI-LEGAL-4 追加式持久化(完成)**:Alembic `20260810_0025` 持久化 approval ID/key/signature/approver/role/organization/expiry/technical package hash;ORM 与迁移均保留 tenant+manifest 唯一约束,无 update/delete API。 + +### 9.50 第三十一阶段:受保护 promotion 与多节点控制(技术完成,现场演练阻断,2026-08-10) + +1. **P0-AI-CI-1 受保护 CI(完成)**:`.github/workflows/provider-promotion.yml` 绑定受保护 shared-staging environment,执行 `collect/finalize`,只上传 allowlist promotion artifacts,并在 job 结束清理 service key、法务材料和 receipt 私钥。 +2. **P0-AI-CI-2 多节点只读 canary(完成)**:新增 `verify-provider-promotion-nodes.py` 与 canary workflow,要求至少两个节点报告同一 release、receipt、key 和 image digest;远程 service-auth 节点强制 HTTPS,loopback 仅用于测试。 +3. **P0-AI-CI-3 撤销与 kill switch(完成)**:支持 revoked key 传播 SLA 轮询、超时失败关闭、全局 promotion kill switch 及跨节点传播确认;readiness 暴露脱敏只读诊断,不输出 service key、私钥或本地文件路径。 +4. **P0-AI-CI-4 现场边界(阻断)**:代码与自动化不能替代双节点真实部署、撤销时延测量和 kill switch 运维演练;未形成真实运行证据前不得标记运营门禁通过。 + +### 9.51 第三十二阶段:release/image 绑定与真实 promotion 闭环(技术完成,外部真实性阻断,2026-08-10) + +1. **P0-AI-IMAGE-1 receipt v2(完成)**:receipt 强制绑定 `target_image_digest=sha256:<64 hex>`;production preflight 验证 `AI_PLATFORM_RELEASE_IMAGE_DIGEST` 与部署的 `AI_PLATFORM_API_IMAGE@sha256:` 一致,release 或 image 漂移均失败关闭。 +2. **P0-AI-IMAGE-2 运维诊断(完成)**:readiness 返回 verification mode、verified、kill switch、release、image digest、receipt id、key id 和 expiry,不返回签名材料或内部路径。 +3. **P0-AI-IMAGE-3 API 边界(完成)**:OpenAPI 仅保留共享预发布登记/读取、receipt payload GET 与 readiness GET;生产不提供 receipt import/write、promotion POST、Provider execute、support access 或身份写 API,身份执行器继续关闭。 +4. **P0-AI-IMAGE-4 真实闭环(阻断)**:共享预发布目前仍未部署 `0025` 且真实 Provider/fallback 门禁未配置;尚无真实具名法务签名、共享 evidence object、`acceptance_run_id`、receipt v2、受保护 promotion bundle 或生产双节点 canary。不得用本机 memory/mock/loopback 结果补造这些证据。 + +本轮结论:六项顺序中的前五项已完成代码、迁移、CI 契约和自动化验证;第六项只能由真实法务与共享预发布/生产运维共同完成,当前保持 **No-Go**。下一次执行顺序固定为:部署当前代码及 Alembic `0025` 到共享预发布,配置真实 Provider 并关闭 fallback,运行 `collect`,由具名法务签署精确技术包,运行 `finalize` 取得真实 `acceptance_run_id` 和 receipt v2,再执行生产只读双节点 canary、撤销 SLA 与 kill switch 演练。 + +### 9.52 第三十三阶段:真实 Provider 技术包与最终发布审查(技术闭环,生产 No-Go,2026-08-11) + +1. **P0-AI-LIVE-1 共享预发布与真实联网(完成)**:共享预发布已升级至 Alembic `20260810_0025`,真实动态 Provider 为 `ccswitch` / `gpt-5.6-sol`,调用开启、fallback 关闭、服务鉴权和反滥用开启。最终 `collect` 对 10 条法律问答和 8 条合同审查 `18/18` 技术通过、问题 `0`,状态为 `needs_legal_review`。 +2. **P0-AI-LIVE-2 精确技术包(完成)**:远端技术包 `provider-technical-review-package.json` 大小约 195 KiB,SHA-256 为 `ed1830430e8049397800cc75e9c24bac85a093a268bb669cf9d8db862adac9f2`。首次复跑发现输出文件名意外带尾随 `?`,安全扩展名校验按预期拒绝;修正命令后未放宽路径规则并成功构包。 +3. **P0-AI-PROMOTION-3 防绕过复核(完成)**:底层 receipt 构造器不再允许旧 HMAC attestation 替代具名审批;shared staging readiness 在 fallback 开启时失败关闭;receipt payload 导出会从持久化字段重建并重新验证具名法务 Ed25519 签名、trusted keyring 和共享 evidence bytes/hash。即使直接篡改数据库并同步重算 acceptance manifest hash,也不能导出 receipt payload。 +4. **P1-WEB-MOBILE-4 窄屏导航(完成)**:移动端改为菜单按钮和抽屉侧栏,补齐遮罩、关闭按钮、路由关闭、`Esc`、滚动锁、工作空间切换和退出;关闭态侧栏使用 `visibility:hidden`,不可被键盘焦点命中。390×844 页面宽度等于视口、控制台 `0 error`。 +5. **P0-REGRESSION-5 最终自动化(完成)**:Python `454 passed, 3 skipped`;3 个 opt-in PostgreSQL 项在真实随机隔离 schema `3/3`;产品黄金 `31/31`;Web typecheck 与 101 路由 production build;npm audit、pip-audit、Bandit、待提交源码 secret 和 diff-check 均通过。 +6. **P0-PRODUCTION-6 正式上线门禁(阻断)**:具名法务尚未对 10 张知识卡和 18 条真实结果签名,因此没有 `acceptance_run_id`、receipt v2 或 promotion bundle。服务器同时缺少 production env、production secrets、preflight 目录、异地备份挂载、TLS/DNS/不可变镜像/阿里云切流证据和双节点 canary;production 容器数为 `0`,根盘约 `91%` 使用。不得启动 production compose 或把 smoke 重命名为 production。 + +发布结论:当前代码、共享预发布技术验收和内部审查已达到可交付给具名法务及生产运维的候选状态,但正式生产仍为 **No-Go**。下一顺序不可跳步:具名法务签署精确技术包和共享 evidence -> `finalize` 生成真实 `acceptance_run_id`/receipt/promotion bundle -> 建立并通过 production preflight、备份恢复和容量门禁 -> 部署至少两个生产节点 -> 执行只读 canary、撤销 SLA、kill switch 与回滚演练 -> 全部门禁通过后才允许切流。 diff --git a/project-docs/V2-production-infrastructure-provider-acceptance-2026-07-16.md b/project-docs/V2-production-infrastructure-provider-acceptance-2026-07-16.md index dc599d21..f6fc3070 100644 --- a/project-docs/V2-production-infrastructure-provider-acceptance-2026-07-16.md +++ b/project-docs/V2-production-infrastructure-provider-acceptance-2026-07-16.md @@ -35,32 +35,49 @@ 运行命令: ```bash +# 1. 共享预发布收集技术包,不登记 acceptance_run_id python3 yuqei-infra/scripts/run-ai-provider-acceptance.py \ - --base-url https:///ai \ + --mode collect \ + --base-url https:///ai \ --service-key-file /etc/yuqei/secrets/ai-platform-service-key \ - --tenant-id default + --tenant-id default \ + --review-package-file /protected/provider-technical-review-package.json + +# 2. 具名法务签署精确技术包后,验签、核验共享证据并登记/签发 receipt v2 +python3 yuqei-infra/scripts/run-ai-provider-acceptance.py \ + --mode finalize \ + --base-url https:///ai \ + --service-key-file /etc/yuqei/secrets/ai-platform-service-key \ + --tenant-id default \ + --review-package-file /protected/provider-technical-review-package.json \ + --legal-approval-file /protected/named-legal-approval.json \ + --legal-keyring-file /protected/legal-trusted-keyring.json \ + --receipt-private-key-file /protected/provider-receipt-ed25519.pem \ + --receipt-key-id provider-acceptance-2026q3 \ + --target-release-id v2.0.0-rc.1 \ + --target-image-digest sha256:<64-hex-image-digest> \ + --receipt-output /release/provider-acceptance-receipt.json \ + --promotion-bundle-output /release/provider-promotion-bundle.json ``` -报告写入 `project-docs/qa-artifacts/v2-ai-provider-acceptance/`,不记录 Provider key。 +报告默认写入 `.runtime/v2-ai-provider-acceptance/`,不记录 Provider key。只有显式提供覆盖 10 条问答、8 条审查和 10 张知识卡的结构化具名法务审批文件,并由具名法务 Ed25519 私钥对规范化技术包签署、由共享预发布受信 keyring 验签,且服务端重新读取共享证据并校验真实内容 hash 时,工具才向启用服务鉴权与 SQLAlchemy 持久化的共享预发布接口登记完整验收清单并返回真实 `acceptance_run_id`;通用 service key 或旧 HMAC 不能替代法务证明,否则保持 `needs_legal_review` 且不登记 ID。 -## 尚未完成 +## 当前状态(2026-08-11 更新) -1. 尚未取得真实 Provider 的 base URL、模型名、价格和 API key,因此不能执行真实模型验收。 -2. 尚未提供正式后端域名和 TLS 证书,因此不能验证 HTTPS edge。 -3. 尚未建立镜像仓库并发布三个 digest 镜像。 -4. 尚未在 3.90 挂载异地备份目录,不能执行真实备份/恢复演练。 -5. 模板、Prompt 和 18 条 Provider 样本仍需具名法务签批。 +1. `192.168.3.90` 的共享预发布已部署至 Alembic `20260810_0025`;AI Platform 使用真实动态 Provider `ccswitch` / `gpt-5.6-sol`,Provider 调用开启、fallback 关闭、服务鉴权和反滥用开启。此前“仍是旧版本、调用关闭、fallback 开启”的描述已失效。 +2. 真实联网 `collect` 已完成 10 条法律问答和 8 条合同审查,技术结果 `18/18` 通过、问题数 `0`,状态严格保持 `needs_legal_review`。技术包位于共享预发布 `.runtime/v2-ai-provider-acceptance/2026-08-11-shared-staging-collect-final/provider-technical-review-package.json`,SHA-256 为 `ed1830430e8049397800cc75e9c24bac85a093a268bb669cf9d8db862adac9f2`。 +3. 10 张知识卡与上述 18 条真实结果尚未由实际具名法务签署精确技术包;共享证据对象、法务 trusted keyring 和 receipt signing key 也尚未形成完整受保护输入。因此没有真实 `acceptance_run_id`、receipt v2 或 promotion bundle,禁止执行 `finalize`。 +4. 生产基础设施仍未建立:`/etc/yuqei/production.env`、`/etc/yuqei/production-secrets`、`/var/lib/yuqei/preflight` 和异地备份挂载均不存在,production 容器数为 `0`;正式 TLS/DNS、不可变镜像和阿里云前端切流也没有验收证据。 +5. 生产双节点 receipt/release/image digest canary、撤销传播 SLA 和 kill switch 演练尚未执行。服务器根盘当前使用率约 `91%`,仅余约 `18 GiB`,正式部署前必须完成容量治理和回滚空间确认。 +6. support access 仍为 deny-only:真实 IdP/MFA、会话绑定、跨节点撤销、真实资源授权后读取、告警消费和具名隐私/法务门禁未完成;support access API 与身份执行器继续关闭。 -在上述外部配置具备前,production preflight 应继续失败,系统保持 smoke/UAT,不切生产流量。 +在上述外部门禁具备前,production preflight 必须继续失败,系统保持共享预发布/UAT,不启动 production compose、不切生产流量。 -## 本轮验证 +## 2026-08-11 验证 -- `python -m pytest yuqei-infra/tests -q`:6 项通过。 -- AI Platform API 全量测试:通过。 -- Contract Web typecheck 与 production build:通过,83 个路由生成成功。 -- production example preflight template mode:通过。 -- Bandit:0 findings;8 个固定 suppression 已由精确 allowlist 测试锁定。 -- `git diff --check`:通过。 -- 3.90 Docker Compose v2.27:base + production override 普通与 `ops` profile 均通过 `config --quiet`;未启动任何 production 容器。 -- 远端渲染结果:Contract Web 未激活;PostgreSQL、Redis、MinIO、Contract API、AI Platform API 均只绑定 `127.0.0.1`;edge 仅暴露 80/443。 -- 当前 smoke `provider_runtime=warning`:调用关闭、fallback 开启、默认 Provider 无 base URL/secret ref,符合“尚未真实验收”的预期。 +- Python 全量:`454 passed, 3 skipped`;3 个 opt-in 项已在 `192.168.3.90:5432/yuqei_contract` 的随机隔离 schema 中单独 `3/3` 通过并清理。 +- Contract Web typecheck 与 production build:通过,101 个路由生成成功;390×844 抽屉、`Esc`、关闭态键盘可达性、正文横向溢出和控制台均通过真实浏览器复核。 +- 产品黄金套件:合同生成 23 条、条款推荐 8 条,共 `31/31` 技术通过,法务状态保持 `needs_legal_review`。 +- 真实 Provider:`18/18` 技术通过、fallback `false`、问题 `0`,技术包 hash 已与远端文件重新计算结果一致。 +- npm audit、pip-audit、Bandit 均为 0 漏洞/0 findings;待提交源码 secret 候选为 0。本地 Git 忽略的 `.env.local` 仍由开发者自行保管且不进入版本库。 +- `git diff --check`、Next.js production build、PostgreSQL 事务/故障回滚/outbox/support access 并发门禁均通过。 diff --git a/yuqei-ai-platform/services/ai-platform-api/.env.example b/yuqei-ai-platform/services/ai-platform-api/.env.example index f4da6072..6209c475 100644 --- a/yuqei-ai-platform/services/ai-platform-api/.env.example +++ b/yuqei-ai-platform/services/ai-platform-api/.env.example @@ -2,7 +2,25 @@ AI_PLATFORM_ENVIRONMENT=local AI_PLATFORM_LOG_LEVEL=INFO AI_PLATFORM_DATABASE_URL=postgresql+psycopg://ai:ai@localhost:5432/yuqei_ai AI_PLATFORM_REDIS_URL=redis://localhost:6379/1 +# Legacy HMAC is test-fixture-only; protected promotion uses Ed25519 keyring. +AI_PLATFORM_PROVIDER_LEGAL_ATTESTATION_SECRET= +AI_PLATFORM_PROVIDER_LEGAL_APPROVAL_TRUSTED_KEYS_FILE= +AI_PLATFORM_PROVIDER_LEGAL_EVIDENCE_ALLOWED_HOSTS= +AI_PLATFORM_PROVIDER_LEGAL_EVIDENCE_TIMEOUT_SECONDS=15 +AI_PLATFORM_PROVIDER_LEGAL_EVIDENCE_MAX_BYTES=10485760 +AI_PLATFORM_PROVIDER_ACCEPTANCE_RECEIPT_FILE= +AI_PLATFORM_PROVIDER_ACCEPTANCE_TRUSTED_KEYS_FILE= +AI_PLATFORM_PROVIDER_ACCEPTANCE_EXPECTED_RECEIPT_SHA256= +AI_PLATFORM_PROVIDER_ACCEPTANCE_RECEIPT_MAX_TTL_SECONDS=604800 +AI_PLATFORM_PROVIDER_ACCEPTANCE_RECEIPT_CLOCK_SKEW_SECONDS=300 +AI_PLATFORM_RELEASE_ID=local +AI_PLATFORM_RELEASE_IMAGE_DIGEST= AI_PLATFORM_PROVIDER_CONFIG_SOURCE=database +AI_PLATFORM_PROVIDER_WIRE_API=chat_completions +AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS=30 +# Local-only convenience; protected deployments resolve api_key_secret_ref from a mounted file. +AI_PLATFORM_PROVIDER_API_KEY= +AI_PLATFORM_PROVIDER_BASE_URL= AI_PLATFORM_ANTI_ABUSE_MODE=disabled AI_PLATFORM_ANTI_ABUSE_HASH_SECRET= AI_PLATFORM_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT=1000 diff --git a/yuqei-ai-platform/services/ai-platform-api/README.md b/yuqei-ai-platform/services/ai-platform-api/README.md index f36ae176..a124e455 100644 --- a/yuqei-ai-platform/services/ai-platform-api/README.md +++ b/yuqei-ai-platform/services/ai-platform-api/README.md @@ -45,25 +45,48 @@ script. ## AI Release Readiness Gate Release readiness combines provider runtime configuration, the offline golden -asset integrity result, citation/source quality policy, and an explicitly -selected live provider audit run: +asset integrity result, citation/source quality policy, and Provider acceptance +evidence promoted from shared staging: ```powershell cd yuqei-ai-platform/services/ai-platform-api $env:PYTHONPATH=(Resolve-Path .\src).Path python -m yuqei_ai_platform_api.release_readiness_gate --format json python -m yuqei_ai_platform_api.release_readiness_gate ` - --acceptance-run-id "" --format json + --acceptance-run-id "" --format json ``` The same result is available from -`GET /api/v1/quality/release-readiness?acceptance_run_id=`. +`GET /api/v1/quality/release-readiness?acceptance_run_id=`. The command performs no provider network call and exits non-zero when the -target environment is blocked. In production, it requires an enabled -non-placeholder provider, disabled fallback, a resolvable credential, passing -golden asset integrity and citation policy checks, and a matching successful -audit run. Golden asset integrity is reported as offline evidence only and is -never presented as proof that a live provider passed acceptance testing. +target environment is blocked. Shared staging validates an aggregate manifest +covering 10 legal QA runs, 8 contract review runs, 10 knowledge cards, and 18 +exact audit bindings. The manifest is registered only with a named legal +Ed25519 approval envelope whose signature is checked against the trusted legal +keyring; the signed technical package and shared evidence object are re-hashed +by the server. The old HMAC attestation is rejected by the protected API. + +Production does not query the staging manifest or its 18 audit rows. It verifies +an Ed25519-signed receipt and public-key ring from read-only files. The receipt +is bound to the tenant, immutable release ID, Provider endpoint/runtime, prompt +content, quality policy, asset hashes, legal evidence, and a maximum seven-day +validity window. Receipt v2 also binds the exact image digest. Configure the +production runtime with: + +```text +AI_PLATFORM_PROVIDER_ACCEPTANCE_RECEIPT_FILE=/run/yuqei/provider-acceptance/receipt.json +AI_PLATFORM_PROVIDER_ACCEPTANCE_TRUSTED_KEYS_FILE=/run/yuqei/provider-acceptance/trusted-keys.json +AI_PLATFORM_PROVIDER_ACCEPTANCE_EXPECTED_RECEIPT_SHA256= +AI_PLATFORM_RELEASE_ID= +AI_PLATFORM_RELEASE_IMAGE_DIGEST=sha256:<64-hex-image-digest> +AI_PLATFORM_PROVIDER_ACCEPTANCE_KILL_SWITCH=false +``` + +The production process must not receive the receipt signing private key or any +legal signing material. A missing, unpinned, expired, revoked, tampered, cross-tenant, +cross-release, or runtime-mismatched receipt fails closed and never falls back +to a local acceptance database. Golden asset integrity remains offline evidence +only and is never presented as proof that a live provider passed acceptance. Provider calls are disabled by default. To enable an OpenAI-compatible provider: diff --git a/yuqei-ai-platform/services/ai-platform-api/alembic.ini b/yuqei-ai-platform/services/ai-platform-api/alembic.ini index ce3d2312..5a616bcf 100644 --- a/yuqei-ai-platform/services/ai-platform-api/alembic.ini +++ b/yuqei-ai-platform/services/ai-platform-api/alembic.ini @@ -1,6 +1,6 @@ [alembic] -script_location = migrations -prepend_sys_path = src +script_location = %(here)s/migrations +prepend_sys_path = %(here)s/src path_separator = os sqlalchemy.url = postgresql+psycopg://ai:ai@localhost:5432/yuqei_ai diff --git a/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260810_0023_add_provider_acceptance_runs.py b/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260810_0023_add_provider_acceptance_runs.py new file mode 100644 index 00000000..d649a50d --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260810_0023_add_provider_acceptance_runs.py @@ -0,0 +1,107 @@ +"""add fail-closed Provider acceptance manifests + +Revision ID: 20260810_0023 +Revises: 20260722_0022 +Create Date: 2026-08-10 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260810_0023" +down_revision = "20260722_0022" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "provider_acceptance_runs", + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("source_environment", sa.String(length=32), nullable=False), + sa.Column("acceptance_policy_version", sa.String(length=64), nullable=False), + sa.Column("provider_config_id", sa.String(length=64), nullable=False), + sa.Column("provider", sa.String(length=64), nullable=False), + sa.Column("model", sa.String(length=128), nullable=False), + sa.Column("qa_sample_ids_json", sa.Text(), nullable=False), + sa.Column("review_sample_ids_json", sa.Text(), nullable=False), + sa.Column("knowledge_card_ids_json", sa.Text(), nullable=False), + sa.Column("audit_bindings_json", sa.Text(), nullable=False), + sa.Column("suite_manifest_sha256", sa.String(length=64), nullable=False), + sa.Column("knowledge_manifest_sha256", sa.String(length=64), nullable=False), + sa.Column("legal_approval_status", sa.String(length=32), nullable=False), + sa.Column("legal_reviewer", sa.String(length=128), nullable=False), + sa.Column("legal_approved_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("legal_approval_evidence_ref", sa.String(length=1024), nullable=False), + sa.Column("legal_approval_evidence_sha256", sa.String(length=64), nullable=False), + sa.Column("acceptance_manifest_sha256", sa.String(length=64), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "tenant_id", + "acceptance_manifest_sha256", + name="ux_provider_acceptance_runs_tenant_manifest", + ), + ) + op.create_index( + op.f("ix_provider_acceptance_runs_tenant_id"), + "provider_acceptance_runs", + ["tenant_id"], + ) + op.create_index( + op.f("ix_provider_acceptance_runs_source_environment"), + "provider_acceptance_runs", + ["source_environment"], + ) + op.create_index( + op.f("ix_provider_acceptance_runs_provider_config_id"), + "provider_acceptance_runs", + ["provider_config_id"], + ) + op.create_index( + op.f("ix_provider_acceptance_runs_legal_approval_status"), + "provider_acceptance_runs", + ["legal_approval_status"], + ) + op.create_index( + op.f("ix_provider_acceptance_runs_created_at"), + "provider_acceptance_runs", + ["created_at"], + ) + op.create_index( + "ix_provider_acceptance_runs_tenant_created", + "provider_acceptance_runs", + ["tenant_id", "created_at", "id"], + ) + op.create_index( + "ix_provider_acceptance_runs_tenant_provider_created", + "provider_acceptance_runs", + ["tenant_id", "provider_config_id", "created_at", "id"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_provider_acceptance_runs_tenant_provider_created", + table_name="provider_acceptance_runs", + ) + op.drop_index("ix_provider_acceptance_runs_tenant_created", table_name="provider_acceptance_runs") + op.drop_index(op.f("ix_provider_acceptance_runs_created_at"), table_name="provider_acceptance_runs") + op.drop_index( + op.f("ix_provider_acceptance_runs_legal_approval_status"), + table_name="provider_acceptance_runs", + ) + op.drop_index( + op.f("ix_provider_acceptance_runs_provider_config_id"), + table_name="provider_acceptance_runs", + ) + op.drop_index( + op.f("ix_provider_acceptance_runs_source_environment"), + table_name="provider_acceptance_runs", + ) + op.drop_index(op.f("ix_provider_acceptance_runs_tenant_id"), table_name="provider_acceptance_runs") + op.drop_table("provider_acceptance_runs") diff --git a/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260810_0024_add_legal_attestation.py b/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260810_0024_add_legal_attestation.py new file mode 100644 index 00000000..35bbb788 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260810_0024_add_legal_attestation.py @@ -0,0 +1,23 @@ +"""persist the independent legal approval attestation""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260810_0024" +down_revision = "20260810_0023" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "provider_acceptance_runs", + sa.Column("legal_approval_attestation", sa.String(length=64), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("provider_acceptance_runs", "legal_approval_attestation") diff --git a/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260810_0025_add_named_legal_approval.py b/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260810_0025_add_named_legal_approval.py new file mode 100644 index 00000000..eef5462a --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260810_0025_add_named_legal_approval.py @@ -0,0 +1,72 @@ +"""persist named Ed25519 legal approval metadata + +Revision ID: 20260810_0025 +Revises: 20260810_0024 +Create Date: 2026-08-10 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260810_0025" +down_revision = "20260810_0024" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "provider_acceptance_runs", + sa.Column("technical_package_sha256", sa.String(length=64), nullable=True), + ) + op.add_column( + "provider_acceptance_runs", + sa.Column("legal_approval_id", sa.String(length=64), nullable=True), + ) + op.add_column( + "provider_acceptance_runs", + sa.Column("legal_approval_key_id", sa.String(length=64), nullable=True), + ) + op.add_column( + "provider_acceptance_runs", + sa.Column("legal_approval_signature", sa.String(length=128), nullable=True), + ) + op.add_column( + "provider_acceptance_runs", + sa.Column("legal_approval_approver_id", sa.String(length=128), nullable=True), + ) + op.add_column( + "provider_acceptance_runs", + sa.Column("legal_approval_role", sa.String(length=128), nullable=True), + ) + op.add_column( + "provider_acceptance_runs", + sa.Column("legal_approval_organization", sa.String(length=128), nullable=True), + ) + op.add_column( + "provider_acceptance_runs", + sa.Column("legal_approval_expires_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index( + op.f("ix_provider_acceptance_runs_legal_approval_id"), + "provider_acceptance_runs", + ["legal_approval_id"], + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_provider_acceptance_runs_legal_approval_id"), + table_name="provider_acceptance_runs", + ) + op.drop_column("provider_acceptance_runs", "legal_approval_expires_at") + op.drop_column("provider_acceptance_runs", "legal_approval_organization") + op.drop_column("provider_acceptance_runs", "legal_approval_role") + op.drop_column("provider_acceptance_runs", "legal_approval_approver_id") + op.drop_column("provider_acceptance_runs", "legal_approval_signature") + op.drop_column("provider_acceptance_runs", "legal_approval_key_id") + op.drop_column("provider_acceptance_runs", "legal_approval_id") + op.drop_column("provider_acceptance_runs", "technical_package_sha256") diff --git a/yuqei-ai-platform/services/ai-platform-api/pyproject.toml b/yuqei-ai-platform/services/ai-platform-api/pyproject.toml index ebc28e8c..7b22ba96 100644 --- a/yuqei-ai-platform/services/ai-platform-api/pyproject.toml +++ b/yuqei-ai-platform/services/ai-platform-api/pyproject.toml @@ -6,12 +6,13 @@ requires-python = ">=3.12" dependencies = [ "alembic>=1.16", "boto3>=1.35", + "cryptography>=43", "fastapi>=0.115", "httpx>=0.28", "pydantic>=2.10", "pydantic-settings>=2.7", "psycopg[binary]>=3.2", - "pypdf>=5.0", + "pypdf>=6.15.0", "python-multipart>=0.0.20", "python-docx>=1.1", "redis>=5.0", diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/anti_abuse.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/anti_abuse.py index 74efd601..3feaff2f 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/anti_abuse.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/anti_abuse.py @@ -478,7 +478,7 @@ def ai_platform_endpoint_risk_policies(api_prefix: str) -> tuple[EndpointRiskPol p = api_prefix.rstrip("/") return ( EndpointRiskPolicy("ai_runs.list", "GET", f"{p}/audit/ai-runs", "S2", 20, 200, 60, "records", True), - EndpointRiskPolicy("ai_runs.detail", "GET", f"{p}/audit/ai-runs/", "S4", 10, 100, 30, "records", True), + EndpointRiskPolicy("ai_runs.detail", "GET", f"{p}/audit/ai-runs/", "S4", 30, 100, 30, "records", True), EndpointRiskPolicy("quality_actions", "GET", f"{p}/audit/quality-improvement-actions", "S2", 20, 200, 60, "records", True), EndpointRiskPolicy("prompts.list", "GET", f"{p}/prompt-templates", "S3", 20, 200, 60, "records", True), EndpointRiskPolicy("prompts.changes", "GET", f"{p}/prompt-templates/changes", "S3", 20, 200, 60, "records", True), diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/legal.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/legal.py index 27f49edf..e36123e2 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/legal.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/legal.py @@ -81,6 +81,8 @@ def register_legal_routes( store: AiPlatformRepository, provider_adapter: ModelProviderAdapter, ) -> None: + fallback_allowed = settings.provider_fallback_enabled and settings.environment != "production" + @app.post( f"{settings.api_prefix}/legal/qa", response_model=LegalQaResponse, @@ -116,7 +118,7 @@ def register_legal_routes( if provider_response.status == "succeeded" and provider_response.answer: response = fallback_response.model_copy(update={"answer": provider_response.answer}) audit_status = "succeeded" - elif settings.provider_fallback_enabled: + elif fallback_allowed: response = fallback_response audit_status = "fallback" else: @@ -223,7 +225,7 @@ def register_legal_routes( else: provider_status = "failed" provider_error = "Provider contract review output failed structured validation." - if settings.provider_fallback_enabled: + if fallback_allowed: response = review_contract(request, retrieved_citations=citations) audit_status = "fallback" else: @@ -236,7 +238,7 @@ def register_legal_routes( confidence=0.0, ) audit_status = "failed" - elif settings.provider_fallback_enabled: + elif fallback_allowed: response = review_contract(request, retrieved_citations=citations) audit_status = "fallback" else: diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/quality.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/quality.py index a098d088..fbb5f8d0 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/quality.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/quality.py @@ -7,15 +7,39 @@ from yuqei_ai_platform_api.golden_samples import ( load_golden_sample_coverage, run_golden_sample_evaluation, ) +from yuqei_ai_platform_api.legal_evidence import verify_legal_evidence from yuqei_ai_platform_api.repository import ( AiPlatformRepository, AiQualityRuleConfig, AiQualityRuleConfigCreate, ) +from yuqei_ai_platform_api.provider_acceptance import ( + ACCEPTANCE_POLICY_VERSION, + ProviderAcceptanceRun, + ProviderAcceptanceRunCreate, + ProviderAcceptanceRunRegistration, + ProviderAcceptanceAuditBinding, + provider_audit_snapshot_hash, + validate_provider_acceptance_run, +) +from yuqei_ai_platform_api.provider_acceptance_receipt import ( + ProviderAcceptanceReceiptPayload, + build_receipt_payload, +) +from yuqei_ai_platform_api.provider_legal_approval import ( + LEGAL_APPROVAL_ALGORITHM, + LEGAL_APPROVAL_VERSION, + LegalApprovalAuditBinding, + ProviderLegalApprovalPayload, + SignedProviderLegalApproval, + load_legal_keyring_file, + verify_legal_approval, +) from yuqei_ai_platform_api.release_readiness import ( AiReleaseReadiness, evaluate_ai_release_readiness, ) +from yuqei_ai_platform_api.tenant_context import current_tenant_id def register_quality_routes( @@ -61,6 +85,301 @@ def register_quality_routes( def evaluate_golden_samples() -> GoldenSampleEvaluationReport: return run_golden_sample_evaluation() + @app.post( + f"{settings.api_prefix}/quality/provider-acceptance-runs", + response_model=ProviderAcceptanceRun, + status_code=201, + tags=["quality", "internal"], + ) + def register_provider_acceptance_run( + payload: ProviderAcceptanceRunRegistration, + ) -> ProviderAcceptanceRun: + if settings.environment != "staging": + raise HTTPException( + status_code=403, + detail="Provider acceptance manifests can only be registered in shared staging.", + ) + if settings.repository_backend != "sqlalchemy" or not settings.require_service_auth: + raise HTTPException( + status_code=503, + detail="Shared-staging persistence and service authentication are required.", + ) + keyring_file = (settings.provider_legal_approval_trusted_keys_file or "").strip() + allowed_evidence_hosts = { + item.strip().lower().rstrip(".") + for item in settings.provider_legal_evidence_allowed_hosts.split(",") + if item.strip() + } + if not keyring_file or not allowed_evidence_hosts: + raise HTTPException( + status_code=503, + detail="Named legal approval keyring and evidence host allowlist are required.", + ) + if payload.legal_approval_attestation: + raise HTTPException( + status_code=422, + detail="Legacy HMAC legal attestations are not accepted by the protected registration API.", + ) + try: + legal_keyring = load_legal_keyring_file(keyring_file) + except (OSError, ValueError) as error: + raise HTTPException( + status_code=503, + detail="Named legal approval keyring could not be loaded safely.", + ) from error + + legal_payload = payload.legal_approval.payload + try: + legal_problems = verify_legal_approval( + payload.legal_approval, + keyring=legal_keyring, + tenant_id=current_tenant_id(), + provider_config_id=payload.provider_config_id, + provider=payload.provider, + model=payload.model, + technical_evidence_sha256=payload.technical_package_sha256, + ) + except (TypeError, ValueError) as error: + raise HTTPException( + status_code=422, + detail={ + "message": "Named legal approval envelope is invalid.", + "problems": [str(error)], + }, + ) from error + if set(legal_payload.qa_sample_ids) != set(payload.qa_sample_ids): + legal_problems.append("legal approval QA inventory does not match the registration") + if set(legal_payload.review_sample_ids) != set(payload.review_sample_ids): + legal_problems.append("legal approval review inventory does not match the registration") + if set(legal_payload.knowledge_card_ids) != set(payload.knowledge_card_ids): + legal_problems.append("legal approval knowledge inventory does not match the registration") + if legal_payload.suite_manifest_sha256 != payload.suite_manifest_sha256: + legal_problems.append("legal approval suite manifest does not match the registration") + if legal_payload.knowledge_manifest_sha256 != payload.knowledge_manifest_sha256: + legal_problems.append("legal approval knowledge manifest does not match the registration") + registered_bindings = { + (item.suite, item.sample_id, item.audit_run_id) for item in payload.audit_bindings + } + approved_bindings = { + (item.suite, item.sample_id, item.audit_run_id) for item in legal_payload.audit_bindings + } + if registered_bindings != approved_bindings: + legal_problems.append("legal approval audit bindings do not match the registration") + compatibility_fields = ( + (payload.legal_reviewer, legal_payload.reviewer, "legal reviewer"), + (payload.legal_approved_at, legal_payload.approved_at, "legal approval time"), + ( + payload.legal_approval_evidence_ref, + legal_payload.evidence_ref, + "legal evidence reference", + ), + ( + payload.legal_approval_evidence_sha256, + legal_payload.evidence_sha256, + "legal evidence hash", + ), + ) + for supplied, signed, label in compatibility_fields: + if supplied is not None and supplied != signed: + legal_problems.append(f"legacy {label} conflicts with the signed legal approval") + if legal_problems: + raise HTTPException( + status_code=422, + detail={"message": "Named legal approval validation failed.", "problems": legal_problems}, + ) + try: + verify_legal_evidence( + legal_payload.evidence_ref, + legal_payload.evidence_sha256, + allowed_hosts=allowed_evidence_hosts, + max_bytes=settings.provider_legal_evidence_max_bytes, + timeout_seconds=settings.provider_legal_evidence_timeout_seconds, + ) + except ValueError as error: + raise HTTPException( + status_code=422, + detail={ + "message": "Named legal approval evidence validation failed.", + "problems": [str(error)], + }, + ) from error + + provider_config = store.get_default_provider_config() + quality_rules = store.get_quality_rule_config() + try: + audit_bindings = [ + ProviderAcceptanceAuditBinding( + **binding.model_dump(), + audit_snapshot_sha256=provider_audit_snapshot_hash( + store.get_ai_run_audit(binding.audit_run_id) + ), + ) + for binding in payload.audit_bindings + ] + except (KeyError, LookupError) as error: + raise HTTPException( + status_code=422, + detail="Provider acceptance audit run was not found for this tenant.", + ) from error + manifest = ProviderAcceptanceRunCreate( + source_environment="shared_staging", + acceptance_policy_version=ACCEPTANCE_POLICY_VERSION, + provider_config_id=payload.provider_config_id, + provider=payload.provider, + model=payload.model, + qa_sample_ids=payload.qa_sample_ids, + review_sample_ids=payload.review_sample_ids, + knowledge_card_ids=payload.knowledge_card_ids, + audit_bindings=audit_bindings, + suite_manifest_sha256=payload.suite_manifest_sha256, + knowledge_manifest_sha256=payload.knowledge_manifest_sha256, + legal_approval_status="approved", + legal_reviewer=legal_payload.reviewer, + legal_approved_at=legal_payload.approved_at, + legal_approval_evidence_ref=legal_payload.evidence_ref, + legal_approval_evidence_sha256=legal_payload.evidence_sha256, + technical_package_sha256=payload.technical_package_sha256, + legal_approval_id=payload.legal_approval.approval_id, + legal_approval_key_id=legal_payload.key_id, + legal_approval_signature=payload.legal_approval.signature, + legal_approval_approver_id=legal_payload.approver_id, + legal_approval_role=legal_payload.role, + legal_approval_organization=legal_payload.organization, + legal_approval_expires_at=legal_payload.expires_at, + ) + problems = validate_provider_acceptance_run( + manifest, + store=store, + provider_config=provider_config, + quality_rules=quality_rules, + tenant_id=current_tenant_id(), + ) + if problems: + raise HTTPException( + status_code=422, + detail={"message": "Provider acceptance manifest validation failed.", "problems": problems}, + ) + return store.add_provider_acceptance_run(manifest) + + @app.get( + f"{settings.api_prefix}/quality/provider-acceptance-runs/{{run_id}}", + response_model=ProviderAcceptanceRun, + tags=["quality", "internal"], + ) + def get_provider_acceptance_run(run_id: str) -> ProviderAcceptanceRun: + if not settings.require_service_auth: + raise HTTPException(status_code=503, detail="Service authentication is required.") + try: + return store.get_provider_acceptance_run(run_id) + except (KeyError, LookupError) as error: + raise HTTPException(status_code=404, detail="Provider acceptance run not found.") from error + + @app.get( + f"{settings.api_prefix}/quality/provider-acceptance-runs/{{run_id}}/receipt-payload", + response_model=ProviderAcceptanceReceiptPayload, + tags=["quality", "internal"], + ) + def get_provider_acceptance_receipt_payload( + run_id: str, + key_id: str = Query(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"), + target_release_id: str = Query( + min_length=1, + max_length=128, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", + ), + target_image_digest: str = Query(pattern=r"^sha256:[0-9a-f]{64}$"), + ttl_seconds: int = Query(default=86_400, ge=300, le=604_800), + ) -> ProviderAcceptanceReceiptPayload: + if ( + settings.environment != "staging" + or settings.repository_backend != "sqlalchemy" + or not settings.require_service_auth + ): + raise HTTPException( + status_code=403, + detail="Receipt payload export is only available in authenticated shared staging.", + ) + try: + acceptance_run = store.get_provider_acceptance_run(run_id) + except (KeyError, LookupError) as error: + raise HTTPException(status_code=404, detail="Provider acceptance run not found.") from error + provider_config = store.get_default_provider_config() + quality_rules = store.get_quality_rule_config() + keyring_file = (settings.provider_legal_approval_trusted_keys_file or "").strip() + allowed_evidence_hosts = { + item.strip().lower().rstrip(".") + for item in settings.provider_legal_evidence_allowed_hosts.split(",") + if item.strip() + } + problems = validate_provider_acceptance_run( + acceptance_run, + store=store, + provider_config=provider_config, + quality_rules=quality_rules, + ) + if not all( + ( + acceptance_run.technical_package_sha256, + acceptance_run.legal_approval_id, + acceptance_run.legal_approval_key_id, + acceptance_run.legal_approval_signature, + acceptance_run.legal_approval_approver_id, + acceptance_run.legal_approval_role, + acceptance_run.legal_approval_organization, + acceptance_run.legal_approval_expires_at, + ) + ): + problems.append("acceptance run lacks a named Ed25519 legal approval") + elif not keyring_file or not allowed_evidence_hosts: + problems.append("named legal approval trust policy is unavailable") + else: + try: + legal_keyring = load_legal_keyring_file(keyring_file) + persisted_approval = _rebuild_persisted_legal_approval(acceptance_run) + problems.extend( + verify_legal_approval( + persisted_approval, + keyring=legal_keyring, + tenant_id=current_tenant_id(), + provider_config_id=acceptance_run.provider_config_id, + provider=acceptance_run.provider, + model=acceptance_run.model, + technical_evidence_sha256=( + acceptance_run.technical_package_sha256 or "" + ), + ) + ) + if not problems: + verify_legal_evidence( + acceptance_run.legal_approval_evidence_ref, + acceptance_run.legal_approval_evidence_sha256, + allowed_hosts=allowed_evidence_hosts, + max_bytes=settings.provider_legal_evidence_max_bytes, + timeout_seconds=settings.provider_legal_evidence_timeout_seconds, + ) + except (OSError, TypeError, ValueError): + problems.append("persisted named legal approval could not be reverified") + if problems: + raise HTTPException( + status_code=422, + detail={"message": "Provider acceptance manifest validation failed.", "problems": problems}, + ) + try: + return build_receipt_payload( + acceptance_run=acceptance_run, + provider_config=provider_config, + settings=settings, + quality_rules=quality_rules, + prompt_templates=store.list_prompt_templates(), + tenant_id=current_tenant_id(), + key_id=key_id, + target_release_id=target_release_id, + target_image_digest=target_image_digest, + ttl_seconds=ttl_seconds, + ) + except ValueError as error: + raise HTTPException(status_code=422, detail=str(error)) from error + @app.get( f"{settings.api_prefix}/quality/release-readiness", response_model=AiReleaseReadiness, @@ -74,3 +393,57 @@ def register_quality_routes( store=store, acceptance_run_id=acceptance_run_id, ) + + +def _rebuild_persisted_legal_approval( + run: ProviderAcceptanceRun, +) -> SignedProviderLegalApproval: + required = ( + run.technical_package_sha256, + run.legal_approval_id, + run.legal_approval_key_id, + run.legal_approval_signature, + run.legal_approval_approver_id, + run.legal_approval_role, + run.legal_approval_organization, + run.legal_approval_expires_at, + ) + if not all(required): + raise ValueError("persisted named legal approval is incomplete") + payload = ProviderLegalApprovalPayload( + approval_version=LEGAL_APPROVAL_VERSION, + algorithm=LEGAL_APPROVAL_ALGORITHM, + key_id=run.legal_approval_key_id, + decision="approved", + tenant_id=run.tenant_id, + approver_id=run.legal_approval_approver_id, + reviewer=run.legal_reviewer, + role=run.legal_approval_role, + organization=run.legal_approval_organization, + approved_at=run.legal_approved_at, + expires_at=run.legal_approval_expires_at, + provider_config_id=run.provider_config_id, + provider=run.provider, + model=run.model, + qa_sample_ids=run.qa_sample_ids, + review_sample_ids=run.review_sample_ids, + knowledge_card_ids=run.knowledge_card_ids, + audit_bindings=[ + LegalApprovalAuditBinding( + suite=item.suite, + sample_id=item.sample_id, + audit_run_id=item.audit_run_id, + ) + for item in run.audit_bindings + ], + suite_manifest_sha256=run.suite_manifest_sha256, + knowledge_manifest_sha256=run.knowledge_manifest_sha256, + technical_evidence_sha256=run.technical_package_sha256, + evidence_ref=run.legal_approval_evidence_ref, + evidence_sha256=run.legal_approval_evidence_sha256, + ) + return SignedProviderLegalApproval( + approval_id=run.legal_approval_id, + payload=payload, + signature=run.legal_approval_signature, + ) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/config.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/config.py index 46140a3a..f02b2a93 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/config.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/config.py @@ -24,6 +24,21 @@ class AiPlatformSettings(BaseSettings): provider_request_timeout_seconds: float = 30.0 provider_fallback_enabled: bool = True service_shared_secret: str = "" + provider_legal_attestation_secret: str = "" + # Ed25519 legal approval is the only supported identity for protected promotion. + # The HMAC setting above remains only for old, non-promotion fixtures. + provider_legal_approval_trusted_keys_file: str | None = None + provider_legal_evidence_allowed_hosts: str = "" + provider_legal_evidence_timeout_seconds: float = 15.0 + provider_legal_evidence_max_bytes: int = 10 * 1024 * 1024 + provider_acceptance_receipt_file: str | None = None + provider_acceptance_trusted_keys_file: str | None = None + provider_acceptance_expected_receipt_sha256: str | None = None + provider_acceptance_receipt_max_ttl_seconds: int = 7 * 24 * 60 * 60 + provider_acceptance_receipt_clock_skew_seconds: int = 300 + provider_acceptance_kill_switch: bool = False + release_id: str = "local" + release_image_digest: str = "" require_service_auth: bool = False provider_prompt_token_cost_per_1k: float = 0.0 provider_completion_token_cost_per_1k: float = 0.0 diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/legal_evidence.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/legal_evidence.py new file mode 100644 index 00000000..b6428d62 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/legal_evidence.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import hashlib +import ipaddress +import socket +from dataclasses import dataclass +from typing import Callable +from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + + +MAX_EVIDENCE_BYTES = 10 * 1024 * 1024 +DEFAULT_EVIDENCE_TIMEOUT_SECONDS = 15.0 +SUPPORTED_EVIDENCE_SCHEMES = {"https", "s3", "gs", "az"} + + +@dataclass(frozen=True) +class LegalEvidenceVerification: + uri: str + sha256: str + size_bytes: int + content_type: str | None + + +EvidenceFetcher = Callable[[str, int], bytes] + + +class _NoRedirectHandler(HTTPRedirectHandler): + def redirect_request(self, request, fp, code, msg, headers, newurl): # pragma: no cover - urllib hook + raise ValueError("legal evidence redirects are not permitted") + + +def verify_legal_evidence( + uri: str, + expected_sha256: str, + *, + fetcher: EvidenceFetcher | None = None, + allowed_hosts: set[str] | frozenset[str] | None = None, + max_bytes: int = MAX_EVIDENCE_BYTES, + timeout_seconds: float = DEFAULT_EVIDENCE_TIMEOUT_SECONDS, +) -> LegalEvidenceVerification: + parsed = _validate_uri( + uri, + allowed_hosts=allowed_hosts, + max_bytes=max_bytes, + timeout_seconds=timeout_seconds, + ) + if fetcher is None: + if parsed.scheme != "https": + raise ValueError("object-storage evidence requires a configured fetcher") + content, content_type = _fetch_https( + uri, + max_bytes=max_bytes, + timeout_seconds=timeout_seconds, + ) + else: + content = fetcher(uri, max_bytes) + content_type = None + if not isinstance(content, bytes): + raise ValueError("legal evidence fetcher must return bytes") + if len(content) > max_bytes: + raise ValueError("legal evidence exceeds the configured size limit") + actual_sha256 = hashlib.sha256(content).hexdigest() + if actual_sha256 != expected_sha256.lower(): + raise ValueError("legal evidence content hash does not match the approved hash") + return LegalEvidenceVerification( + uri=uri, + sha256=actual_sha256, + size_bytes=len(content), + content_type=content_type, + ) + + +def _validate_uri( + uri: str, + *, + allowed_hosts: set[str] | frozenset[str] | None, + max_bytes: int, + timeout_seconds: float, +): + if not isinstance(uri, str) or len(uri) > 2048: + raise ValueError("legal evidence URI is invalid") + parsed = urlsplit(uri.strip()) + if parsed.scheme.lower() not in SUPPORTED_EVIDENCE_SCHEMES or not parsed.hostname: + raise ValueError("legal evidence URI scheme or host is not allowed") + if parsed.username or parsed.password or parsed.query or parsed.fragment: + raise ValueError("legal evidence URI must not contain credentials, query, or fragment") + if max_bytes < 1 or max_bytes > MAX_EVIDENCE_BYTES: + raise ValueError("legal evidence size policy is invalid") + if timeout_seconds <= 0 or timeout_seconds > 120: + raise ValueError("legal evidence timeout policy is invalid") + hostname = parsed.hostname.lower().rstrip(".") + if allowed_hosts is not None and hostname not in {item.lower().rstrip(".") for item in allowed_hosts}: + raise ValueError("legal evidence host is not on the allowlist") + if parsed.scheme.lower() == "https": + if parsed.port not in (None, 443): + raise ValueError("legal HTTPS evidence must use port 443") + _reject_private_dns_targets(hostname) + return parsed + + +def _reject_private_dns_targets(hostname: str) -> None: + try: + literal = ipaddress.ip_address(hostname) + except ValueError: + literal = None + if literal is not None: + if _is_private_target(literal): + raise ValueError("legal evidence host resolves to a private address") + return + try: + addresses = socket.getaddrinfo(hostname, 443, type=socket.SOCK_STREAM) + except OSError as error: + raise ValueError("legal evidence host could not be resolved") from error + if not addresses: + raise ValueError("legal evidence host could not be resolved") + for address in addresses: + candidate = ipaddress.ip_address(address[4][0]) + if _is_private_target(candidate): + raise ValueError("legal evidence host resolves to a private address") + + +def _is_private_target(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + return bool( + address.is_private + or address.is_loopback + or address.is_link_local + or address.is_multicast + or address.is_unspecified + or address.is_reserved + ) + + +def _fetch_https( + uri: str, + *, + max_bytes: int, + timeout_seconds: float, +) -> tuple[bytes, str | None]: + request = Request(uri, headers={"Accept": "application/octet-stream", "User-Agent": "yuqei-legal-evidence/1"}) + opener = build_opener(_NoRedirectHandler()) + try: + with opener.open(request, timeout=timeout_seconds) as response: + final_uri = response.geturl() + if final_uri != uri: + raise ValueError("legal evidence redirects are not permitted") + raw_length = response.headers.get("Content-Length") + if raw_length is not None: + try: + if int(raw_length) > max_bytes: + raise ValueError("legal evidence exceeds the configured size limit") + except ValueError as error: + if "exceeds" in str(error): + raise + raise ValueError("legal evidence Content-Length is invalid") from error + body = response.read(max_bytes + 1) + content_type = response.headers.get_content_type() + if len(body) > max_bytes: + raise ValueError("legal evidence exceeds the configured size limit") + return body, content_type + except (HTTPError, URLError) as error: + raise ValueError("legal evidence could not be fetched") from error diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/models.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/models.py index 99c1b551..cd39ed53 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/models.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/models.py @@ -353,6 +353,60 @@ class AiRunAuditModel(Base): ) +class ProviderAcceptanceRunModel(Base): + __tablename__ = "provider_acceptance_runs" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "acceptance_manifest_sha256", + name="ux_provider_acceptance_runs_tenant_manifest", + ), + Index("ix_provider_acceptance_runs_tenant_created", "tenant_id", "created_at", "id"), + Index( + "ix_provider_acceptance_runs_tenant_provider_created", + "tenant_id", + "provider_config_id", + "created_at", + "id", + ), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + source_environment: Mapped[str] = mapped_column(String(32), nullable=False, index=True) + acceptance_policy_version: Mapped[str] = mapped_column(String(64), nullable=False) + provider_config_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + provider: Mapped[str] = mapped_column(String(64), nullable=False) + model: Mapped[str] = mapped_column(String(128), nullable=False) + qa_sample_ids_json: Mapped[str] = mapped_column(Text, nullable=False) + review_sample_ids_json: Mapped[str] = mapped_column(Text, nullable=False) + knowledge_card_ids_json: Mapped[str] = mapped_column(Text, nullable=False) + audit_bindings_json: Mapped[str] = mapped_column(Text, nullable=False) + suite_manifest_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + knowledge_manifest_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + legal_approval_status: Mapped[str] = mapped_column(String(32), nullable=False, index=True) + legal_reviewer: Mapped[str] = mapped_column(String(128), nullable=False) + legal_approved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + legal_approval_evidence_ref: Mapped[str] = mapped_column(String(1024), nullable=False) + legal_approval_evidence_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + legal_approval_attestation: Mapped[str | None] = mapped_column(String(64), nullable=True) + technical_package_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) + legal_approval_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + legal_approval_key_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + legal_approval_signature: Mapped[str | None] = mapped_column(String(128), nullable=True) + legal_approval_approver_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + legal_approval_role: Mapped[str | None] = mapped_column(String(128), nullable=True) + legal_approval_organization: Mapped[str | None] = mapped_column(String(128), nullable=True) + legal_approval_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + acceptance_manifest_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + index=True, + ) + + class AiQualityImprovementActionModel(Base): __tablename__ = "ai_quality_improvement_actions" diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_acceptance.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_acceptance.py new file mode 100644 index 00000000..3af1ccb8 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_acceptance.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +import csv +import hashlib +import hmac +import json +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Literal +from urllib.parse import urlparse + +from pydantic import BaseModel, Field + +from yuqei_ai_platform_api.provider_legal_approval import SignedProviderLegalApproval + + +ACCEPTANCE_POLICY_VERSION = "phase13-provider-acceptance-v1" +PHASE13_ROOT = ( + Path(__file__).with_name("golden_assets") + / "v1_phase13_legal_knowledge_template_library" +) +QA_INVENTORY = PHASE13_ROOT / "inventories" / "kb-qa-golden-sample-inventory.csv" +REVIEW_INVENTORY = PHASE13_ROOT / "inventories" / "review-golden-sample-inventory.csv" +KNOWLEDGE_CARD_INVENTORY = ( + PHASE13_ROOT / "inventories" / "legal-knowledge-card-inventory.csv" +) +_PLACEHOLDER_REVIEWERS = { + "-", + "pending", + "pending legal review", + "待审批", + "待法务复核", + "待法务审批", + "法务", + "legal", +} +_SHARED_EVIDENCE_SCHEMES = {"https", "s3", "gs", "az"} + + +class ProviderAcceptanceAuditBinding(BaseModel): + suite: Literal["legal_qa", "contract_review"] + sample_id: str = Field(min_length=1, max_length=128) + audit_run_id: str = Field(min_length=1, max_length=64) + audit_snapshot_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class ProviderAcceptanceAuditBindingRegistration(BaseModel): + suite: Literal["legal_qa", "contract_review"] + sample_id: str = Field(min_length=1, max_length=128) + audit_run_id: str = Field(min_length=1, max_length=64) + + +class ProviderAcceptanceRunCreate(BaseModel): + source_environment: Literal["shared_staging"] + acceptance_policy_version: Literal[ACCEPTANCE_POLICY_VERSION] + provider_config_id: str = Field(min_length=1, max_length=64) + provider: str = Field(min_length=1, max_length=64) + model: str = Field(min_length=1, max_length=128) + qa_sample_ids: list[str] = Field(min_length=10, max_length=10) + review_sample_ids: list[str] = Field(min_length=8, max_length=8) + knowledge_card_ids: list[str] = Field(min_length=10, max_length=10) + audit_bindings: list[ProviderAcceptanceAuditBinding] = Field(min_length=18, max_length=18) + suite_manifest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + knowledge_manifest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + legal_approval_status: Literal["approved"] + legal_reviewer: str = Field(min_length=2, max_length=128) + legal_approved_at: datetime + legal_approval_evidence_ref: str = Field(min_length=1, max_length=1024) + legal_approval_evidence_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + legal_approval_attestation: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + # These fields are populated by the protected staging registration path. + # They remain optional so legacy, read-only repository/readiness fixtures can + # continue to model historical manifests without claiming a named approval. + technical_package_sha256: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + legal_approval_id: str | None = Field(default=None, pattern=r"^provider-legal-approval-[0-9a-f]{24}$") + legal_approval_key_id: str | None = Field( + default=None, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$", + ) + legal_approval_signature: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{86}$") + legal_approval_approver_id: str | None = Field( + default=None, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$", + ) + legal_approval_role: str | None = Field(default=None, min_length=2, max_length=128) + legal_approval_organization: str | None = Field(default=None, min_length=2, max_length=128) + legal_approval_expires_at: datetime | None = None + + +class ProviderAcceptanceRunRegistration(BaseModel): + provider_config_id: str = Field(min_length=1, max_length=64) + provider: str = Field(min_length=1, max_length=64) + model: str = Field(min_length=1, max_length=128) + qa_sample_ids: list[str] = Field(min_length=10, max_length=10) + review_sample_ids: list[str] = Field(min_length=8, max_length=8) + knowledge_card_ids: list[str] = Field(min_length=10, max_length=10) + audit_bindings: list[ProviderAcceptanceAuditBindingRegistration] = Field(min_length=18, max_length=18) + suite_manifest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + knowledge_manifest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + # The protected API accepts only this signed, named legal envelope. The + # historical fields below are optional compatibility inputs; the endpoint + # always derives their values from ``legal_approval.payload``. + legal_approval: SignedProviderLegalApproval + technical_package_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + legal_approval_status: Literal["approved"] | None = None + legal_reviewer: str | None = Field(default=None, min_length=2, max_length=128) + legal_approved_at: datetime | None = None + legal_approval_evidence_ref: str | None = Field(default=None, min_length=1, max_length=1024) + legal_approval_evidence_sha256: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + legal_approval_attestation: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + + +class ProviderAcceptanceRun(ProviderAcceptanceRunCreate): + id: str + tenant_id: str + acceptance_manifest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + created_at: datetime + + +def expected_acceptance_inventory() -> dict[str, list[str] | str]: + return { + "qa_sample_ids": _inventory_ids(QA_INVENTORY, "sample_id"), + "review_sample_ids": _inventory_ids(REVIEW_INVENTORY, "sample_id"), + "knowledge_card_ids": _inventory_ids(KNOWLEDGE_CARD_INVENTORY, "knowledge_card_id"), + "suite_manifest_sha256": _manifest_hash([QA_INVENTORY, REVIEW_INVENTORY]), + "knowledge_manifest_sha256": _manifest_hash([KNOWLEDGE_CARD_INVENTORY]), + } + + +def validate_provider_acceptance_run( + run: ProviderAcceptanceRun | ProviderAcceptanceRunCreate, + *, + store: Any, + provider_config: Any, + quality_rules: Any, + legal_attestation_secret: str | None = None, + tenant_id: str | None = None, +) -> list[str]: + expected = expected_acceptance_inventory() + expected_questions = _expected_sample_questions() + problems: list[str] = [] + if isinstance(run, ProviderAcceptanceRun): + persisted_payload = ProviderAcceptanceRunCreate.model_validate( + run.model_dump( + exclude={"id", "tenant_id", "acceptance_manifest_sha256", "created_at"} + ) + ) + expected_manifest_hash = provider_acceptance_manifest_hash(run.tenant_id, persisted_payload) + if run.acceptance_manifest_sha256 != expected_manifest_hash: + problems.append("acceptance manifest hash does not match persisted evidence") + _require_exact_set("legal QA sample", run.qa_sample_ids, expected["qa_sample_ids"], problems) + _require_exact_set( + "contract review sample", + run.review_sample_ids, + expected["review_sample_ids"], + problems, + ) + _require_exact_set( + "knowledge card", + run.knowledge_card_ids, + expected["knowledge_card_ids"], + problems, + ) + if run.suite_manifest_sha256 != expected["suite_manifest_sha256"]: + problems.append("suite manifest hash does not match the bundled acceptance assets") + if run.knowledge_manifest_sha256 != expected["knowledge_manifest_sha256"]: + problems.append("knowledge manifest hash does not match the bundled knowledge-card assets") + + if provider_config is None or run.provider_config_id != provider_config.id: + problems.append("manifest does not use the active default provider configuration") + if provider_config is None or run.provider != provider_config.provider: + problems.append("manifest provider does not match the active default provider") + if provider_config is None or run.model != provider_config.model: + problems.append("manifest model does not match the active default provider model") + + expected_bindings = { + *(('legal_qa', sample_id) for sample_id in expected["qa_sample_ids"]), + *(('contract_review', sample_id) for sample_id in expected["review_sample_ids"]), + } + observed_bindings = {(item.suite, item.sample_id) for item in run.audit_bindings} + if observed_bindings != expected_bindings or len(observed_bindings) != len(run.audit_bindings): + problems.append("audit bindings do not cover each required suite sample exactly once") + audit_run_ids = [item.audit_run_id for item in run.audit_bindings] + if len(set(audit_run_ids)) != len(audit_run_ids): + problems.append("audit bindings must use 18 distinct audit runs") + + audit_created_at: list[datetime] = [] + for binding in run.audit_bindings: + try: + audit = store.get_ai_run_audit(binding.audit_run_id) + except (KeyError, LookupError): + problems.append(f"audit run is missing or belongs to another tenant: {binding.audit_run_id}") + continue + if binding.audit_snapshot_sha256 != provider_audit_snapshot_hash(audit): + problems.append(f"audit snapshot hash does not match accepted evidence: {binding.sample_id}") + audit_created_at.append(audit.created_at) + if audit.operation != binding.suite: + problems.append(f"audit operation does not match sample suite: {binding.sample_id}") + expected_question = expected_questions.get((binding.suite, binding.sample_id)) + if expected_question is None or audit.question != expected_question: + problems.append(f"audit is not bound to the exact bundled sample input: {binding.sample_id}") + if provider_config is None or audit.provider_config_id != provider_config.id: + problems.append(f"audit does not use the active provider configuration: {binding.sample_id}") + if provider_config is None or audit.provider != provider_config.provider: + problems.append(f"audit provider does not match the active provider: {binding.sample_id}") + if provider_config is None or audit.model != provider_config.model: + problems.append(f"audit model does not match the active provider model: {binding.sample_id}") + if audit.status != "succeeded" or audit.provider_status != "succeeded" or audit.provider_error: + problems.append(f"audit did not succeed without Provider error: {binding.sample_id}") + quality_codes = {label.code for label in audit.quality_labels} + if {"fallback_used", "provider_failed"} & quality_codes: + problems.append(f"audit contains fallback or Provider failure evidence: {binding.sample_id}") + if audit.quality_status != "passed": + problems.append(f"audit quality status is not passed: {binding.sample_id}") + if audit.citations_count < quality_rules.min_citation_count: + problems.append(f"audit citation count is below policy: {binding.sample_id}") + if len(audit.retrieved_sources) < quality_rules.min_source_count: + problems.append(f"audit retrieved-source count is below policy: {binding.sample_id}") + + reviewer = run.legal_reviewer.strip() + if reviewer.lower() in _PLACEHOLDER_REVIEWERS or reviewer in _PLACEHOLDER_REVIEWERS: + problems.append("legal reviewer must identify a named approver") + approved_at = run.legal_approved_at + if approved_at.tzinfo is None or approved_at.utcoffset() is None: + problems.append("legal approval time must be timezone-aware") + elif approved_at.astimezone(UTC) > datetime.now(UTC): + problems.append("legal approval time cannot be in the future") + elif any(created_at.astimezone(UTC) > approved_at.astimezone(UTC) for created_at in audit_created_at): + problems.append("legal approval time must follow every accepted Provider output") + evidence = urlparse(run.legal_approval_evidence_ref.strip()) + if ( + evidence.scheme.lower() not in _SHARED_EVIDENCE_SCHEMES + or not evidence.netloc + or evidence.username + or evidence.password + or evidence.hostname in {"localhost", "127.0.0.1", "::1"} + ): + problems.append("legal approval evidence must use a shared HTTPS or object-storage reference") + if run.legal_approval_evidence_sha256 in {"0" * 64, "a" * 64, "f" * 64}: + problems.append("legal approval evidence hash must not be a placeholder") + if legal_attestation_secret is not None: + if not legal_attestation_secret.strip(): + problems.append("independent legal attestation secret is not configured") + elif not run.legal_approval_attestation: + problems.append("independent legal approval attestation is required") + elif not hmac.compare_digest( + run.legal_approval_attestation, + provider_acceptance_attestation( + run.tenant_id if isinstance(run, ProviderAcceptanceRun) else (tenant_id or "default"), + run, + legal_attestation_secret, + ), + ): + problems.append("legal approval attestation does not match the acceptance manifest") + return problems + + +def provider_acceptance_manifest_hash( + tenant_id: str, + payload: ProviderAcceptanceRunCreate, +) -> str: + normalized = _normalized_acceptance_payload(payload) + canonical = json.dumps( + {"tenant_id": tenant_id, **normalized}, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +def provider_acceptance_attestation( + tenant_id: str, + payload: ProviderAcceptanceRunCreate, + secret: str, +) -> str: + normalized = _normalized_acceptance_payload(payload, exclude_attestation=True) + normalized["audit_bindings"] = [ + { + "suite": item["suite"], + "sample_id": item["sample_id"], + "audit_run_id": item["audit_run_id"], + } + for item in normalized["audit_bindings"] + ] + canonical = json.dumps( + {"tenant_id": tenant_id, **normalized}, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hmac.new(secret.encode("utf-8"), canonical, hashlib.sha256).hexdigest() + + +def _normalized_acceptance_payload( + payload: ProviderAcceptanceRunCreate, + *, + exclude_attestation: bool = False, +) -> dict[str, Any]: + base_payload = ( + ProviderAcceptanceRunCreate.model_validate( + payload.model_dump( + exclude={"id", "tenant_id", "acceptance_manifest_sha256", "created_at"} + ) + ) + if isinstance(payload, ProviderAcceptanceRun) + else payload + ) + normalized = base_payload.model_dump( + mode="json", + exclude={"legal_approval_attestation"} if exclude_attestation else None, + ) + if ( + base_payload.legal_approved_at.tzinfo is not None + and base_payload.legal_approved_at.utcoffset() is not None + ): + normalized["legal_approved_at"] = base_payload.legal_approved_at.astimezone(UTC).isoformat() + for field_name in ("qa_sample_ids", "review_sample_ids", "knowledge_card_ids"): + normalized[field_name] = sorted(normalized[field_name]) + normalized["audit_bindings"] = sorted( + normalized["audit_bindings"], + key=lambda item: (item["suite"], item["sample_id"], item["audit_run_id"]), + ) + return normalized + + +def provider_audit_snapshot_hash(audit: Any) -> str: + def value(name: str, default: Any = None) -> Any: + return audit.get(name, default) if isinstance(audit, dict) else getattr(audit, name, default) + + created_at = value("created_at") + if isinstance(created_at, str): + created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00")) + labels = value("quality_labels", []) or [] + sources = value("retrieved_sources", []) or [] + snapshot = { + "id": value("id"), + "tenant_id": value("tenant_id"), + "operation": value("operation"), + "provider_config_id": value("provider_config_id"), + "prompt_template_id": value("prompt_template_id"), + "provider": value("provider"), + "model": value("model"), + "provider_status": value("provider_status"), + "provider_error": value("provider_error"), + "status": value("status"), + "quality_status": value("quality_status"), + "quality_score": value("quality_score"), + "quality_label_codes": sorted( + str(label.get("code")) if isinstance(label, dict) else str(label.code) + for label in labels + ), + "citations_count": value("citations_count", 0), + "retrieved_source_count": len(sources), + "question_sha256": hashlib.sha256((value("question") or "").encode("utf-8")).hexdigest(), + "created_at": created_at.astimezone(UTC).isoformat() + if created_at.tzinfo is not None and created_at.utcoffset() is not None + else created_at.isoformat(), + } + canonical = json.dumps(snapshot, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +def _inventory_ids(path: Path, field: str) -> list[str]: + with path.open("r", encoding="utf-8-sig", newline="") as stream: + return [str(row[field]).strip() for row in csv.DictReader(stream)] + + +def _expected_sample_questions() -> dict[tuple[str, str], str]: + expected: dict[tuple[str, str], str] = {} + with QA_INVENTORY.open("r", encoding="utf-8-sig", newline="") as stream: + for row in csv.DictReader(stream): + expected[("legal_qa", row["sample_id"].strip())] = row["question"] + with REVIEW_INVENTORY.open("r", encoding="utf-8-sig", newline="") as stream: + for row in csv.DictReader(stream): + sample_id = row["sample_id"].strip() + expected[("contract_review", sample_id)] = ( + f"{sample_id} {row.get('contract_type', '')} {row.get('risk_categories', '')}" + ) + return expected + + +def _manifest_hash(paths: list[Path]) -> str: + digest = hashlib.sha256() + for path in paths: + digest.update(path.name.encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + +def _require_exact_set( + label: str, + observed: list[str], + expected: list[str] | str, + problems: list[str], +) -> None: + if not isinstance(expected, list): + problems.append(f"{label} bundled inventory definition is invalid") + return + if len(set(observed)) != len(observed) or set(observed) != set(expected): + problems.append(f"{label} inventory is incomplete or does not match bundled assets") diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_acceptance_receipt.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_acceptance_receipt.py new file mode 100644 index 00000000..dff4fd15 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_acceptance_receipt.py @@ -0,0 +1,483 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import re +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any, Literal +from urllib.parse import urlparse, urlsplit, urlunsplit + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey +from pydantic import BaseModel, ConfigDict, Field + +from yuqei_ai_platform_api.provider_acceptance import ( + ACCEPTANCE_POLICY_VERSION, + ProviderAcceptanceRun, + ProviderAcceptanceRunCreate, + expected_acceptance_inventory, + provider_acceptance_manifest_hash, +) + + +RECEIPT_VERSION = "provider-acceptance-receipt-v2" +RECEIPT_ALGORITHM = "Ed25519" +RECEIPT_ISSUER = "shared_staging" +RECEIPT_AUDIENCE = "yuqei-ai-platform-production" +RECEIPT_TARGET_ENVIRONMENT = "production" +RECEIPT_DOMAIN = b"YUQEI_PROVIDER_ACCEPTANCE_RECEIPT_V1\0" +MAX_RECEIPT_FILE_BYTES = 1024 * 1024 +MAX_KEYRING_FILE_BYTES = 256 * 1024 +SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +IMAGE_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +_PLACEHOLDER_REVIEWERS = {"-", "pending", "pending legal review", "legal", "法务"} +_SHARED_EVIDENCE_SCHEMES = {"https", "s3", "gs", "az"} + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class ProviderRuntimeBinding(StrictModel): + provider: str = Field(min_length=1, max_length=64) + model: str = Field(min_length=1, max_length=128) + base_url: str = Field(min_length=1, max_length=2048) + wire_api: Literal["chat_completions", "responses"] + temperature: float + max_tokens: int = Field(ge=1) + prompt_templates_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + fingerprint_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class ProviderAcceptanceReceiptPayload(StrictModel): + receipt_version: Literal[RECEIPT_VERSION] + algorithm: Literal[RECEIPT_ALGORITHM] + key_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") + issuer: Literal[RECEIPT_ISSUER] + audience: Literal[RECEIPT_AUDIENCE] + source_environment: Literal["shared_staging"] + target_environment: Literal["production"] + target_release_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") + target_image_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + tenant_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") + acceptance_run: ProviderAcceptanceRun + provider_runtime: ProviderRuntimeBinding + quality_policy_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + issued_at: datetime + not_before: datetime + expires_at: datetime + + +class SignedProviderAcceptanceReceipt(StrictModel): + receipt_id: str = Field(pattern=r"^provider-receipt-[0-9a-f]{24}$") + payload: ProviderAcceptanceReceiptPayload + signature: str = Field(pattern=r"^[A-Za-z0-9_-]{86}$") + + +class ProviderAcceptancePublicKey(StrictModel): + key_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") + algorithm: Literal[RECEIPT_ALGORITHM] + public_key_pem: str = Field(min_length=1, max_length=8192) + not_before: datetime + not_after: datetime + revoked: bool = False + + +class ProviderAcceptancePublicKeyring(StrictModel): + version: Literal[1] + keys: list[ProviderAcceptancePublicKey] = Field(min_length=1, max_length=32) + + +def build_receipt_payload( + *, + acceptance_run: ProviderAcceptanceRun, + provider_config: Any, + settings: Any, + quality_rules: Any, + prompt_templates: list[Any], + tenant_id: str, + key_id: str, + target_release_id: str, + target_image_digest: str, + now: datetime | None = None, + ttl_seconds: int = 86_400, +) -> ProviderAcceptanceReceiptPayload: + if not SAFE_IDENTIFIER.fullmatch(target_release_id): + raise ValueError("target release id is invalid") + if not IMAGE_DIGEST.fullmatch(target_image_digest): + raise ValueError("target image digest is invalid") + if not 300 <= ttl_seconds <= 7 * 24 * 60 * 60: + raise ValueError("receipt TTL must be between 300 and 604800 seconds") + issued_at = _as_utc(now or datetime.now(UTC)) + runtime = build_provider_runtime_binding( + provider_config=provider_config, + settings=settings, + prompt_templates=prompt_templates, + ) + if acceptance_run.tenant_id != tenant_id: + raise ValueError("acceptance run tenant does not match the receipt tenant") + acceptance_problems = _validate_embedded_acceptance_run( + acceptance_run, + runtime, + receipt_issued_at=issued_at, + clock_skew_seconds=30, + ) + if acceptance_problems: + raise ValueError("acceptance run is not eligible for promotion: " + "; ".join(acceptance_problems)) + return ProviderAcceptanceReceiptPayload( + receipt_version=RECEIPT_VERSION, + algorithm=RECEIPT_ALGORITHM, + key_id=key_id, + issuer=RECEIPT_ISSUER, + audience=RECEIPT_AUDIENCE, + source_environment="shared_staging", + target_environment=RECEIPT_TARGET_ENVIRONMENT, + target_release_id=target_release_id, + target_image_digest=target_image_digest, + tenant_id=tenant_id, + acceptance_run=acceptance_run, + provider_runtime=runtime, + quality_policy_sha256=quality_policy_hash(quality_rules), + issued_at=issued_at, + not_before=issued_at - timedelta(seconds=30), + expires_at=issued_at + timedelta(seconds=ttl_seconds), + ) + + +def build_provider_runtime_binding( + *, + provider_config: Any, + settings: Any, + prompt_templates: list[Any], +) -> ProviderRuntimeBinding: + if provider_config is None: + raise ValueError("default provider configuration is required") + base_url = _normalize_base_url(provider_config.base_url or settings.provider_base_url or "") + prompt_hash = prompt_templates_hash(prompt_templates) + unsigned = { + "provider": provider_config.provider, + "model": provider_config.model, + "base_url": base_url, + "wire_api": settings.provider_wire_api, + "temperature": provider_config.temperature, + "max_tokens": provider_config.max_tokens, + "prompt_templates_sha256": prompt_hash, + } + return ProviderRuntimeBinding( + **unsigned, + fingerprint_sha256=_sha256_json(unsigned), + ) + + +def prompt_templates_hash(prompt_templates: list[Any]) -> str: + published = [] + for template in prompt_templates: + if not getattr(template, "enabled", False) or getattr(template, "status", "") != "published": + continue + published.append( + { + "name": template.name, + "version": template.version, + "content_sha256": hashlib.sha256(template.content.encode("utf-8")).hexdigest(), + } + ) + published.sort(key=lambda item: (item["name"], item["version"], item["content_sha256"])) + return _sha256_json(published) + + +def quality_policy_hash(quality_rules: Any) -> str: + fields = ( + "min_citation_count", + "min_source_count", + "high_token_threshold", + "high_latency_ms", + "critical_latency_ms", + "warning_penalty", + "error_penalty", + ) + return _sha256_json({field: getattr(quality_rules, field) for field in fields}) + + +def sign_receipt_payload( + payload: ProviderAcceptanceReceiptPayload, + private_key_pem: bytes, +) -> SignedProviderAcceptanceReceipt: + key = serialization.load_pem_private_key(private_key_pem, password=None) + if not isinstance(key, Ed25519PrivateKey): + raise ValueError("receipt signing key must be an Ed25519 private key") + canonical = canonical_receipt_payload(payload) + signature = key.sign(RECEIPT_DOMAIN + canonical) + return SignedProviderAcceptanceReceipt( + receipt_id=_receipt_id(canonical), + payload=payload, + signature=base64.urlsafe_b64encode(signature).decode("ascii").rstrip("="), + ) + + +def verify_receipt( + receipt: SignedProviderAcceptanceReceipt, + *, + keyring: ProviderAcceptancePublicKeyring, + tenant_id: str, + target_release_id: str, + target_image_digest: str, + provider_config: Any, + settings: Any, + quality_rules: Any, + prompt_templates: list[Any], + now: datetime | None = None, + max_ttl_seconds: int = 7 * 24 * 60 * 60, + clock_skew_seconds: int = 300, +) -> list[str]: + problems: list[str] = [] + if max_ttl_seconds < 300: + raise ValueError("receipt maximum TTL policy is invalid") + if not 0 <= clock_skew_seconds <= 3600: + raise ValueError("receipt clock-skew policy is invalid") + checked_at = _as_utc(now or datetime.now(UTC)) + payload = receipt.payload + canonical = canonical_receipt_payload(payload) + if receipt.receipt_id != _receipt_id(canonical): + problems.append("receipt id does not match the signed payload") + keys = [item for item in keyring.keys if item.key_id == payload.key_id] + if len(keys) != 1: + problems.append("receipt signing key is unknown or duplicated") + else: + key_entry = keys[0] + key_not_before = _as_utc(key_entry.not_before) + key_not_after = _as_utc(key_entry.not_after) + if key_not_after <= key_not_before: + problems.append("receipt signing key validity window is invalid") + if key_entry.revoked: + problems.append("receipt signing key is revoked") + if key_not_before > checked_at + timedelta(seconds=clock_skew_seconds): + problems.append("receipt signing key is not active yet") + if checked_at >= key_not_after: + problems.append("receipt signing key is expired") + if key_not_before > _as_utc(payload.issued_at) + timedelta(seconds=clock_skew_seconds): + problems.append("receipt predates the signing key validity window") + if _as_utc(payload.expires_at) > key_not_after: + problems.append("receipt lifetime exceeds the signing key validity window") + try: + public_key = serialization.load_pem_public_key(key_entry.public_key_pem.encode("utf-8")) + if not isinstance(public_key, Ed25519PublicKey): + raise ValueError("not Ed25519") + public_key.verify(_decode_signature(receipt.signature), RECEIPT_DOMAIN + canonical) + except (InvalidSignature, ValueError, TypeError): + problems.append("receipt signature is invalid") + + issued_at = _as_utc(payload.issued_at) + not_before = _as_utc(payload.not_before) + expires_at = _as_utc(payload.expires_at) + if issued_at > checked_at + timedelta(seconds=clock_skew_seconds): + problems.append("receipt was issued in the future") + if not_before > checked_at + timedelta(seconds=clock_skew_seconds): + problems.append("receipt is not active yet") + if checked_at >= expires_at: + problems.append("receipt is expired") + if not_before > issued_at + timedelta(seconds=clock_skew_seconds): + problems.append("receipt activation time is after its issue time") + if expires_at <= issued_at or (expires_at - issued_at).total_seconds() > max_ttl_seconds: + problems.append("receipt lifetime exceeds policy") + if payload.tenant_id != tenant_id or payload.acceptance_run.tenant_id != tenant_id: + problems.append("receipt tenant does not match the active tenant") + if payload.target_release_id != target_release_id: + problems.append("receipt target release does not match the active release") + if payload.target_image_digest != target_image_digest: + problems.append("receipt target image digest does not match the active image") + + expected_runtime = build_provider_runtime_binding( + provider_config=provider_config, + settings=settings, + prompt_templates=prompt_templates, + ) + if payload.provider_runtime != expected_runtime: + problems.append("Provider runtime fingerprint does not match the accepted runtime") + if payload.quality_policy_sha256 != quality_policy_hash(quality_rules): + problems.append("quality policy does not match the accepted policy") + problems.extend( + _validate_embedded_acceptance_run( + payload.acceptance_run, + payload.provider_runtime, + receipt_issued_at=issued_at, + clock_skew_seconds=clock_skew_seconds, + ) + ) + return problems + + +def load_receipt_file( + path: str | Path, + *, + expected_sha256: str | None = None, +) -> SignedProviderAcceptanceReceipt: + raw = _read_bounded_file(path, MAX_RECEIPT_FILE_BYTES) + if expected_sha256 and hashlib.sha256(raw).hexdigest() != expected_sha256: + raise ValueError("receipt file hash does not match the deployment pin") + return SignedProviderAcceptanceReceipt.model_validate(_strict_json_loads(raw)) + + +def load_keyring_file(path: str | Path) -> ProviderAcceptancePublicKeyring: + raw = _read_bounded_file(path, MAX_KEYRING_FILE_BYTES) + keyring = ProviderAcceptancePublicKeyring.model_validate(_strict_json_loads(raw)) + key_ids = [item.key_id for item in keyring.keys] + if len(key_ids) != len(set(key_ids)): + raise ValueError("public keyring contains duplicate key ids") + return keyring + + +def canonical_receipt_payload(payload: ProviderAcceptanceReceiptPayload) -> bytes: + return json.dumps( + payload.model_dump(mode="json"), + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def _validate_embedded_acceptance_run( + run: ProviderAcceptanceRun, + runtime: ProviderRuntimeBinding, + *, + receipt_issued_at: datetime, + clock_skew_seconds: int, +) -> list[str]: + problems: list[str] = [] + expected = expected_acceptance_inventory() + create = ProviderAcceptanceRunCreate.model_validate( + run.model_dump(exclude={"id", "tenant_id", "acceptance_manifest_sha256", "created_at"}) + ) + if run.acceptance_manifest_sha256 != provider_acceptance_manifest_hash(run.tenant_id, create): + problems.append("acceptance manifest hash is invalid") + expected_run_id = f"provider-acceptance-{run.acceptance_manifest_sha256[:24]}" + if run.id != expected_run_id: + problems.append("acceptance run id does not match its manifest hash") + if run.source_environment != "shared_staging" or run.acceptance_policy_version != ACCEPTANCE_POLICY_VERSION: + problems.append("acceptance manifest policy or source is invalid") + if set(run.qa_sample_ids) != set(expected["qa_sample_ids"]) or len(run.qa_sample_ids) != 10: + problems.append("legal QA inventory is incomplete") + if set(run.review_sample_ids) != set(expected["review_sample_ids"]) or len(run.review_sample_ids) != 8: + problems.append("contract review inventory is incomplete") + if set(run.knowledge_card_ids) != set(expected["knowledge_card_ids"]) or len(run.knowledge_card_ids) != 10: + problems.append("knowledge card inventory is incomplete") + if run.suite_manifest_sha256 != expected["suite_manifest_sha256"]: + problems.append("suite asset hash does not match bundled assets") + if run.knowledge_manifest_sha256 != expected["knowledge_manifest_sha256"]: + problems.append("knowledge asset hash does not match bundled assets") + expected_bindings = { + *(("legal_qa", item) for item in expected["qa_sample_ids"]), + *(("contract_review", item) for item in expected["review_sample_ids"]), + } + observed_bindings = {(item.suite, item.sample_id) for item in run.audit_bindings} + audit_ids = [item.audit_run_id for item in run.audit_bindings] + if observed_bindings != expected_bindings or len(run.audit_bindings) != 18 or len(set(audit_ids)) != 18: + problems.append("audit bindings are incomplete or duplicated") + if run.provider != runtime.provider or run.model != runtime.model: + problems.append("accepted Provider identity does not match the runtime binding") + named_approval_fields = ( + run.technical_package_sha256, + run.legal_approval_id, + run.legal_approval_key_id, + run.legal_approval_signature, + run.legal_approval_approver_id, + run.legal_approval_role, + run.legal_approval_organization, + run.legal_approval_expires_at, + ) + has_named_approval = all(named_approval_fields) + if any(named_approval_fields) and not has_named_approval: + problems.append("accepted manifest has incomplete named legal approval metadata") + if run.legal_approval_status != "approved" or not has_named_approval: + problems.append("accepted manifest lacks a complete named legal approval") + if has_named_approval: + legal_expiry = _as_utc(run.legal_approval_expires_at) # type: ignore[arg-type] + if legal_expiry <= receipt_issued_at: + problems.append("named legal approval is expired") + reviewer = run.legal_reviewer.strip() + if reviewer.lower() in _PLACEHOLDER_REVIEWERS or reviewer in _PLACEHOLDER_REVIEWERS: + problems.append("accepted manifest lacks a named legal reviewer") + evidence = urlparse(run.legal_approval_evidence_ref.strip()) + if ( + evidence.scheme.lower() not in _SHARED_EVIDENCE_SCHEMES + or not evidence.netloc + or evidence.username + or evidence.password + or evidence.hostname in {"localhost", "127.0.0.1", "::1"} + ): + problems.append("accepted manifest legal evidence reference is not shared") + if run.legal_approval_evidence_sha256 in {"0" * 64, "a" * 64, "f" * 64}: + problems.append("accepted manifest legal evidence hash is a placeholder") + approval_time = _as_utc(run.legal_approved_at) + created_at = _as_utc(run.created_at) + latest_allowed = receipt_issued_at + timedelta(seconds=clock_skew_seconds) + if approval_time > latest_allowed or created_at > latest_allowed: + problems.append("accepted manifest or legal approval postdates the receipt") + return problems + + +def _normalize_base_url(value: str) -> str: + parsed = urlsplit(value.strip()) + if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname: + raise ValueError("Provider base URL is invalid") + if parsed.username or parsed.password or parsed.query or parsed.fragment: + raise ValueError("Provider base URL must not contain credentials, query, or fragment") + hostname = parsed.hostname.lower() + port = parsed.port + default_port = (parsed.scheme.lower() == "https" and port == 443) or ( + parsed.scheme.lower() == "http" and port == 80 + ) + netloc = hostname if port is None or default_port else f"{hostname}:{port}" + path = parsed.path.rstrip("/") + return urlunsplit((parsed.scheme.lower(), netloc, path, "", "")) + + +def _receipt_id(canonical: bytes) -> str: + return f"provider-receipt-{hashlib.sha256(RECEIPT_DOMAIN + canonical).hexdigest()[:24]}" + + +def _decode_signature(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + decoded = base64.b64decode(value + padding, altchars=b"-_", validate=True) + if len(decoded) != 64: + raise ValueError("invalid signature length") + return decoded + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("receipt timestamps must be timezone-aware") + return value.astimezone(UTC) + + +def _sha256_json(value: Any) -> str: + canonical = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +def _read_bounded_file(path: str | Path, limit: int) -> bytes: + resolved = Path(path).resolve(strict=True) + if not resolved.is_file(): + raise ValueError("receipt evidence file is missing or too large") + with resolved.open("rb") as stream: + raw = stream.read(limit + 1) + if len(raw) > limit: + raise ValueError("receipt evidence file is missing or too large") + return raw + + +def _strict_json_loads(raw: bytes) -> Any: + def reject_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON key: {key}") + result[key] = value + return result + + try: + return json.loads(raw.decode("utf-8"), object_pairs_hook=reject_duplicates) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError("receipt evidence is not valid UTF-8 JSON") from error diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_legal_approval.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_legal_approval.py new file mode 100644 index 00000000..bcd8672f --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_legal_approval.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import re +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any, Literal +from urllib.parse import urlparse + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey +from pydantic import BaseModel, ConfigDict, Field + + +LEGAL_APPROVAL_VERSION = "provider-legal-approval-v1" +LEGAL_APPROVAL_ALGORITHM = "Ed25519" +LEGAL_APPROVAL_DOMAIN = b"YUQEI_PROVIDER_LEGAL_APPROVAL_V1\0" +MAX_LEGAL_APPROVAL_FILE_BYTES = 256 * 1024 +MAX_LEGAL_KEYRING_FILE_BYTES = 256 * 1024 +SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$") +SHARED_EVIDENCE_SCHEMES = {"https", "s3", "gs", "az"} + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class LegalApprovalAuditBinding(StrictModel): + suite: Literal["legal_qa", "contract_review"] + sample_id: str = Field(min_length=1, max_length=128) + audit_run_id: str = Field(min_length=1, max_length=64) + + +class ProviderLegalApprovalPayload(StrictModel): + approval_version: Literal[LEGAL_APPROVAL_VERSION] + algorithm: Literal[LEGAL_APPROVAL_ALGORITHM] + key_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") + decision: Literal["approved"] + tenant_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") + approver_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$") + reviewer: str = Field(min_length=2, max_length=128) + role: str = Field(min_length=2, max_length=128) + organization: str = Field(min_length=2, max_length=128) + approved_at: datetime + expires_at: datetime + provider_config_id: str = Field(min_length=1, max_length=64) + provider: str = Field(min_length=1, max_length=64) + model: str = Field(min_length=1, max_length=128) + qa_sample_ids: list[str] = Field(min_length=10, max_length=10) + review_sample_ids: list[str] = Field(min_length=8, max_length=8) + knowledge_card_ids: list[str] = Field(min_length=10, max_length=10) + audit_bindings: list[LegalApprovalAuditBinding] = Field(min_length=18, max_length=18) + suite_manifest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + knowledge_manifest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + technical_evidence_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + evidence_ref: str = Field(min_length=1, max_length=1024) + evidence_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class SignedProviderLegalApproval(StrictModel): + approval_id: str = Field(pattern=r"^provider-legal-approval-[0-9a-f]{24}$") + payload: ProviderLegalApprovalPayload + signature: str = Field(pattern=r"^[A-Za-z0-9_-]{86}$") + + +class ProviderLegalApprovalPublicKey(StrictModel): + key_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") + algorithm: Literal[LEGAL_APPROVAL_ALGORITHM] + public_key_pem: str = Field(min_length=1, max_length=8192) + approver_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$") + reviewer: str = Field(min_length=2, max_length=128) + role: str = Field(min_length=2, max_length=128) + organization: str = Field(min_length=2, max_length=128) + not_before: datetime + not_after: datetime + revoked: bool = False + + +class ProviderLegalApprovalKeyring(StrictModel): + version: Literal[1] + keys: list[ProviderLegalApprovalPublicKey] = Field(min_length=1, max_length=64) + + +def sign_legal_approval( + payload: ProviderLegalApprovalPayload, + private_key_pem: bytes, +) -> SignedProviderLegalApproval: + problems = validate_legal_approval_payload(payload) + if problems: + raise ValueError("legal approval payload is invalid: " + "; ".join(problems)) + key = serialization.load_pem_private_key(private_key_pem, password=None) + if not isinstance(key, Ed25519PrivateKey): + raise ValueError("legal approval signing key must be an Ed25519 private key") + canonical = canonical_legal_approval_payload(payload) + signature = key.sign(LEGAL_APPROVAL_DOMAIN + canonical) + return SignedProviderLegalApproval( + approval_id=_approval_id(canonical), + payload=payload, + signature=base64.urlsafe_b64encode(signature).decode("ascii").rstrip("="), + ) + + +def verify_legal_approval( + approval: SignedProviderLegalApproval, + *, + keyring: ProviderLegalApprovalKeyring, + tenant_id: str, + provider_config_id: str, + provider: str, + model: str, + technical_evidence_sha256: str, + now: datetime | None = None, + clock_skew_seconds: int = 300, + max_validity_seconds: int = 30 * 24 * 60 * 60, +) -> list[str]: + if not 0 <= clock_skew_seconds <= 3600: + raise ValueError("legal approval clock-skew policy is invalid") + if not 300 <= max_validity_seconds <= 90 * 24 * 60 * 60: + raise ValueError("legal approval validity policy is invalid") + checked_at = _as_utc(now or datetime.now(UTC)) + payload = approval.payload + canonical = canonical_legal_approval_payload(payload) + problems = validate_legal_approval_payload( + payload, + now=checked_at, + clock_skew_seconds=clock_skew_seconds, + max_validity_seconds=max_validity_seconds, + ) + if approval.approval_id != _approval_id(canonical): + problems.append("legal approval id does not match the signed payload") + if payload.tenant_id != tenant_id: + problems.append("legal approval tenant does not match") + if payload.provider_config_id != provider_config_id: + problems.append("legal approval Provider configuration does not match") + if payload.provider != provider or payload.model != model: + problems.append("legal approval Provider identity does not match") + if payload.technical_evidence_sha256 != technical_evidence_sha256: + problems.append("legal approval technical evidence hash does not match") + + matches = [item for item in keyring.keys if item.key_id == payload.key_id] + if len(matches) != 1: + problems.append("legal approval signing key is unknown or duplicated") + return problems + key_entry = matches[0] + key_not_before = _as_utc(key_entry.not_before) + key_not_after = _as_utc(key_entry.not_after) + approved_at = _as_utc(payload.approved_at) + if key_entry.revoked: + problems.append("legal approval signing key is revoked") + if key_not_after <= key_not_before: + problems.append("legal approval signing key validity window is invalid") + if approved_at < key_not_before or approved_at >= key_not_after: + problems.append("legal approval was signed outside the key validity window") + identity_fields = ("approver_id", "reviewer", "role", "organization") + if any(getattr(payload, field) != getattr(key_entry, field) for field in identity_fields): + problems.append("legal approval identity does not match the trusted key owner") + try: + public_key = serialization.load_pem_public_key(key_entry.public_key_pem.encode("utf-8")) + if not isinstance(public_key, Ed25519PublicKey): + raise ValueError("not Ed25519") + public_key.verify( + _decode_signature(approval.signature), + LEGAL_APPROVAL_DOMAIN + canonical, + ) + except (InvalidSignature, TypeError, ValueError): + problems.append("legal approval signature is invalid") + return problems + + +def validate_legal_approval_payload( + payload: ProviderLegalApprovalPayload, + *, + now: datetime | None = None, + clock_skew_seconds: int = 300, + max_validity_seconds: int = 30 * 24 * 60 * 60, +) -> list[str]: + problems: list[str] = [] + approved_at = _as_utc(payload.approved_at) + expires_at = _as_utc(payload.expires_at) + if now is not None and approved_at > _as_utc(now) + timedelta(seconds=clock_skew_seconds): + problems.append("legal approval time is in the future") + if expires_at <= approved_at: + problems.append("legal approval expiry must follow approval time") + elif (expires_at - approved_at).total_seconds() > max_validity_seconds: + problems.append("legal approval validity exceeds policy") + if now is not None and _as_utc(now) >= expires_at: + problems.append("legal approval is expired") + _require_unique_inventory(payload.qa_sample_ids, 10, "legal QA", problems) + _require_unique_inventory(payload.review_sample_ids, 8, "contract review", problems) + _require_unique_inventory(payload.knowledge_card_ids, 10, "knowledge card", problems) + observed = {(item.suite, item.sample_id) for item in payload.audit_bindings} + audit_ids = [item.audit_run_id for item in payload.audit_bindings] + if len(observed) != 18 or len(set(audit_ids)) != 18: + problems.append("legal approval audit bindings must contain 18 distinct results") + expected = { + *(("legal_qa", item) for item in payload.qa_sample_ids), + *(("contract_review", item) for item in payload.review_sample_ids), + } + if observed != expected: + problems.append("legal approval audit bindings do not match the approved samples") + evidence = urlparse(payload.evidence_ref.strip()) + if ( + evidence.scheme.lower() not in SHARED_EVIDENCE_SCHEMES + or not evidence.netloc + or evidence.username + or evidence.password + or evidence.hostname in {"localhost", "127.0.0.1", "::1"} + ): + problems.append("legal approval evidence reference is not a shared URI") + if payload.evidence_sha256 in {"0" * 64, "a" * 64, "f" * 64}: + problems.append("legal approval evidence hash is a placeholder") + if payload.technical_evidence_sha256 in {"0" * 64, "a" * 64, "f" * 64}: + problems.append("legal approval technical evidence hash is a placeholder") + return problems + + +def load_legal_approval_file(path: str | Path) -> SignedProviderLegalApproval: + raw = _read_bounded_file(path, MAX_LEGAL_APPROVAL_FILE_BYTES) + return SignedProviderLegalApproval.model_validate(_strict_json_loads(raw)) + + +def load_legal_keyring_file(path: str | Path) -> ProviderLegalApprovalKeyring: + raw = _read_bounded_file(path, MAX_LEGAL_KEYRING_FILE_BYTES) + keyring = ProviderLegalApprovalKeyring.model_validate(_strict_json_loads(raw)) + key_ids = [item.key_id for item in keyring.keys] + if len(key_ids) != len(set(key_ids)): + raise ValueError("legal approval keyring contains duplicate key ids") + return keyring + + +def canonical_legal_approval_payload(payload: ProviderLegalApprovalPayload) -> bytes: + normalized = payload.model_dump(mode="json") + for field in ("qa_sample_ids", "review_sample_ids", "knowledge_card_ids"): + normalized[field] = sorted(normalized[field]) + normalized["audit_bindings"] = sorted( + normalized["audit_bindings"], + key=lambda item: (item["suite"], item["sample_id"], item["audit_run_id"]), + ) + for field in ("approved_at", "expires_at"): + normalized[field] = _as_utc(getattr(payload, field)).isoformat() + return json.dumps( + normalized, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def _approval_id(canonical: bytes) -> str: + digest = hashlib.sha256(LEGAL_APPROVAL_DOMAIN + canonical).hexdigest() + return f"provider-legal-approval-{digest[:24]}" + + +def _decode_signature(value: str) -> bytes: + decoded = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True) + if len(decoded) != 64: + raise ValueError("invalid legal approval signature length") + return decoded + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("legal approval timestamps must be timezone-aware") + return value.astimezone(UTC) + + +def _require_unique_inventory( + values: list[str], + expected_count: int, + label: str, + problems: list[str], +) -> None: + if len(values) != expected_count or len(set(values)) != expected_count: + problems.append(f"legal approval {label} inventory is incomplete or duplicated") + + +def _read_bounded_file(path: str | Path, limit: int) -> bytes: + resolved = Path(path).resolve(strict=True) + if not resolved.is_file(): + raise ValueError("legal approval evidence file is missing or too large") + with resolved.open("rb") as stream: + raw = stream.read(limit + 1) + if len(raw) > limit: + raise ValueError("legal approval evidence file is missing or too large") + return raw + + +def _strict_json_loads(raw: bytes) -> Any: + def reject_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON key: {key}") + result[key] = value + return result + + try: + return json.loads(raw.decode("utf-8"), object_pairs_hook=reject_duplicates) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError("legal approval evidence is not valid UTF-8 JSON") from error diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_review_package.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_review_package.py new file mode 100644 index 00000000..b9fe7310 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_review_package.py @@ -0,0 +1,88 @@ +"""Deterministic, reviewable technical evidence for Provider promotion. + +The package deliberately contains no credentials or raw Provider request payloads. It +is the object a named legal approver signs after the technical run has finished. +""" + +from __future__ import annotations + +import hashlib +import json +from datetime import UTC, datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +REVIEW_PACKAGE_VERSION = "provider-technical-review-package-v1" + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class ProviderReviewAsset(StrictModel): + path: str = Field(min_length=1, max_length=512, pattern=r"^[A-Za-z0-9._/-]+$") + sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + size_bytes: int = Field(ge=0, le=20 * 1024 * 1024) + + +class ProviderReviewCase(StrictModel): + suite: Literal["legal_qa", "contract_review"] + sample_id: str = Field(min_length=1, max_length=128) + audit_run_id: str = Field(min_length=1, max_length=64) + audit_snapshot_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + technical_status: Literal["passed"] + provider: str = Field(min_length=1, max_length=64) + model: str = Field(min_length=1, max_length=128) + latency_ms: int = Field(ge=0, le=3_600_000) + output: dict[str, Any] + output_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class ProviderTechnicalReviewPackage(StrictModel): + package_version: Literal[REVIEW_PACKAGE_VERSION] + tenant_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") + source_environment: Literal["shared_staging"] + created_at: datetime + provider_config_id: str = Field(min_length=1, max_length=64) + provider: str = Field(min_length=1, max_length=64) + model: str = Field(min_length=1, max_length=128) + qa_sample_ids: list[str] = Field(min_length=10, max_length=10) + review_sample_ids: list[str] = Field(min_length=8, max_length=8) + knowledge_card_ids: list[str] = Field(min_length=10, max_length=10) + suite_manifest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + knowledge_manifest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + assets: list[ProviderReviewAsset] = Field(min_length=3, max_length=256) + cases: list[ProviderReviewCase] = Field(min_length=18, max_length=18) + + +def canonical_review_package(package: ProviderTechnicalReviewPackage) -> bytes: + """Serialize with stable ordering; this exact byte string is signed indirectly.""" + normalized = package.model_dump(mode="json") + normalized["created_at"] = _as_utc(package.created_at).isoformat() + for field in ("qa_sample_ids", "review_sample_ids", "knowledge_card_ids"): + normalized[field] = sorted(normalized[field]) + normalized["assets"] = sorted(normalized["assets"], key=lambda item: item["path"]) + normalized["cases"] = sorted( + normalized["cases"], key=lambda item: (item["suite"], item["sample_id"]) + ) + return json.dumps(normalized, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def review_package_sha256(package: ProviderTechnicalReviewPackage) -> str: + return hashlib.sha256(canonical_review_package(package)).hexdigest() + + +def canonical_json(value: Any) -> bytes: + return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("review package timestamps must be timezone-aware") + return value.astimezone(UTC) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/providers.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/providers.py index 333c9781..fdade8c5 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/providers.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/providers.py @@ -97,6 +97,14 @@ class OpenAICompatibleProviderAdapter: json=request_payload, ) response.raise_for_status() + except httpx.TimeoutException: + return ProviderResponse( + answer="", + status="failed", + error_message="Provider request timed out.", + provider=config.provider, + model=config.model, + ) except httpx.HTTPError as exc: return ProviderResponse( answer="", diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/release_readiness.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/release_readiness.py index d0459346..6345ccbe 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/release_readiness.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/release_readiness.py @@ -14,10 +14,21 @@ from yuqei_ai_platform_api.golden_samples import ( from yuqei_ai_platform_api.repository import ( AiPlatformRepository, AiQualityRuleConfig, - AiRunAudit, ProviderConfig, ) +from yuqei_ai_platform_api.provider_acceptance import ( + ProviderAcceptanceRun, + validate_provider_acceptance_run, +) +from yuqei_ai_platform_api.provider_acceptance_receipt import ( + ProviderAcceptancePublicKeyring, + SignedProviderAcceptanceReceipt, + load_keyring_file, + load_receipt_file, + verify_receipt, +) from yuqei_ai_platform_api.secret_refs import resolve_secret_ref +from yuqei_ai_platform_api.tenant_context import current_tenant_id ReleaseReadinessStatus = Literal["ready", "needs_review", "blocked"] @@ -34,6 +45,17 @@ class ReleaseReadinessCheck(BaseModel): details: dict[str, str | bool | int | float | None] = Field(default_factory=dict) +class ProviderPromotionDiagnostics(BaseModel): + verification_mode: Literal["offline_read_only", "repository"] + verified: bool + kill_switch_active: bool + release_id: str + image_digest: str | None + receipt_id: str | None + key_id: str | None + receipt_expires_at: datetime | None + + class AiReleaseReadiness(BaseModel): environment: str status: ReleaseReadinessStatus @@ -42,6 +64,7 @@ class AiReleaseReadiness(BaseModel): generated_at: datetime summary: str checks: list[ReleaseReadinessCheck] + promotion_diagnostics: ProviderPromotionDiagnostics def evaluate_ai_release_readiness( @@ -49,23 +72,69 @@ def evaluate_ai_release_readiness( settings: AiPlatformSettings, store: AiPlatformRepository, acceptance_run_id: str | None = None, + acceptance_receipt: SignedProviderAcceptanceReceipt | None = None, + acceptance_keyring: ProviderAcceptancePublicKeyring | None = None, golden_evaluation: GoldenSampleEvaluationReport | None = None, ) -> AiReleaseReadiness: default_provider = _get_default_provider(store) quality_rules = store.get_quality_rule_config() golden_report = golden_evaluation or run_golden_sample_evaluation() - acceptance_run = _get_acceptance_run(store, acceptance_run_id) + acceptance_run = ( + _get_acceptance_run(store, acceptance_run_id) + if settings.environment != "production" + else None + ) + + receipt_error: str | None = None + if settings.environment == "production": + try: + if acceptance_receipt is None and settings.provider_acceptance_receipt_file: + if not settings.provider_acceptance_expected_receipt_sha256: + raise ValueError("a deployment-pinned receipt SHA-256 is required") + acceptance_receipt = load_receipt_file( + settings.provider_acceptance_receipt_file, + expected_sha256=settings.provider_acceptance_expected_receipt_sha256, + ) + if acceptance_keyring is None and settings.provider_acceptance_trusted_keys_file: + acceptance_keyring = load_keyring_file(settings.provider_acceptance_trusted_keys_file) + except (OSError, TypeError, ValueError) as error: + receipt_error = str(error) checks = [ _provider_runtime_check(settings, default_provider), _golden_asset_check(golden_report), _citation_policy_check(settings.environment, quality_rules), - _provider_acceptance_check( - environment=settings.environment, - acceptance_run_id=acceptance_run_id, - acceptance_run=acceptance_run, - provider_config=default_provider, - quality_rules=quality_rules, + _provider_promotion_kill_switch_check(settings), + ( + _provider_acceptance_receipt_check( + acceptance_run_id=acceptance_run_id, + receipt=acceptance_receipt, + keyring=acceptance_keyring, + receipt_error=receipt_error, + settings=settings, + store=store, + provider_config=default_provider, + quality_rules=quality_rules, + ) + if settings.environment == "production" + else _provider_acceptance_check( + environment=settings.environment, + acceptance_run_id=acceptance_run_id, + acceptance_run=acceptance_run, + store=store, + provider_config=default_provider, + quality_rules=quality_rules, + legal_attestation_secret=( + settings.provider_legal_attestation_secret + if ( + settings.environment == "staging" + and len(settings.provider_legal_attestation_secret.strip()) >= 32 + and settings.provider_legal_attestation_secret.strip() + != settings.service_shared_secret.strip() + ) + else ("" if settings.environment == "staging" else None) + ), + ) ), ] blocking_failures = [check for check in checks if check.blocking and check.status == "failed"] @@ -77,7 +146,7 @@ def evaluate_ai_release_readiness( else: status = "ready" release_allowed = not blocking_failures - acceptance_verified = next( + acceptance_verified = not settings.provider_acceptance_kill_switch and next( check.status == "passed" for check in checks if check.code == "provider_acceptance" ) return AiReleaseReadiness( @@ -91,6 +160,22 @@ def evaluate_ai_release_readiness( f"{len(blocking_failures)} blocking failure(s)." ), checks=checks, + promotion_diagnostics=ProviderPromotionDiagnostics( + verification_mode=( + "offline_read_only" if settings.environment == "production" else "repository" + ), + verified=acceptance_verified, + kill_switch_active=( + settings.environment == "production" and settings.provider_acceptance_kill_switch + ), + release_id=settings.release_id, + image_digest=settings.release_image_digest or None, + receipt_id=acceptance_receipt.receipt_id if acceptance_receipt is not None else None, + key_id=acceptance_receipt.payload.key_id if acceptance_receipt is not None else None, + receipt_expires_at=( + acceptance_receipt.payload.expires_at if acceptance_receipt is not None else None + ), + ), ) @@ -101,12 +186,15 @@ def _get_default_provider(store: AiPlatformRepository) -> ProviderConfig | None: return None -def _get_acceptance_run(store: AiPlatformRepository, run_id: str | None) -> AiRunAudit | None: +def _get_acceptance_run( + store: AiPlatformRepository, + run_id: str | None, +) -> ProviderAcceptanceRun | None: if not run_id: return None try: - return store.get_ai_run_audit(run_id) - except (KeyError, LookupError): + return store.get_provider_acceptance_run(run_id) + except (KeyError, LookupError, TypeError, ValueError): return None @@ -121,7 +209,7 @@ def _provider_runtime_check( problems: list[str] = [] if settings.provider_call_mode != "enabled": problems.append("provider_call_mode must be enabled") - if settings.provider_fallback_enabled: + if strict and settings.provider_fallback_enabled: problems.append("provider fallback must be disabled") if provider_config is None or not provider_config.enabled: problems.append("an enabled default provider is required") @@ -153,7 +241,11 @@ def _provider_runtime_check( code="provider_runtime", status="passed", blocking=strict, - message="A non-placeholder provider is enabled with fallback disabled.", + message=( + "A non-placeholder provider is enabled with fallback disabled." + if strict + else "A non-placeholder provider is enabled for non-production acceptance." + ), evidence_scope="static_runtime_configuration", details={ "provider": provider_name, @@ -166,6 +258,22 @@ def _provider_runtime_check( ) +def _provider_promotion_kill_switch_check(settings: AiPlatformSettings) -> ReleaseReadinessCheck: + active = settings.environment == "production" and settings.provider_acceptance_kill_switch + return ReleaseReadinessCheck( + code="provider_promotion_kill_switch", + status="failed" if active else "passed", + blocking=active, + message=( + "Provider promotion is disabled by the production kill switch." + if active + else "Provider promotion kill switch is inactive." + ), + evidence_scope="runtime_safety_control", + details={"active": active}, + ) + + def _golden_asset_check(report: GoldenSampleEvaluationReport) -> ReleaseReadinessCheck: passed = report.status == "passed" return ReleaseReadinessCheck( @@ -215,13 +323,101 @@ def _citation_policy_check( ) +def _provider_acceptance_receipt_check( + *, + acceptance_run_id: str | None, + receipt: SignedProviderAcceptanceReceipt | None, + keyring: ProviderAcceptancePublicKeyring | None, + receipt_error: str | None, + settings: AiPlatformSettings, + store: AiPlatformRepository, + provider_config: ProviderConfig | None, + quality_rules: AiQualityRuleConfig, +) -> ReleaseReadinessCheck: + problems: list[str] = [] + if receipt_error: + problems.append(f"receipt evidence could not be loaded: {receipt_error}") + if receipt is None: + problems.append("a signed shared-staging acceptance receipt is required") + if keyring is None: + problems.append("a trusted Provider acceptance public keyring is required") + if receipt is not None and acceptance_run_id and receipt.payload.acceptance_run.id != acceptance_run_id: + problems.append("acceptance run id does not match the signed receipt") + if receipt is not None and keyring is not None and not problems: + try: + problems.extend( + verify_receipt( + receipt, + keyring=keyring, + tenant_id=current_tenant_id(), + target_release_id=settings.release_id, + target_image_digest=settings.release_image_digest, + provider_config=provider_config, + settings=settings, + quality_rules=quality_rules, + prompt_templates=store.list_prompt_templates(), + max_ttl_seconds=settings.provider_acceptance_receipt_max_ttl_seconds, + clock_skew_seconds=settings.provider_acceptance_receipt_clock_skew_seconds, + ) + ) + except Exception: + problems.append("signed acceptance receipt could not be verified") + if problems: + return ReleaseReadinessCheck( + code="provider_acceptance", + status="failed", + blocking=True, + message="Production requires a valid read-only shared-staging acceptance receipt.", + evidence_scope="signed_shared_staging_acceptance_receipt", + details={ + "acceptance_run_id": ( + receipt.payload.acceptance_run.id if receipt is not None else acceptance_run_id + ), + "receipt_id": receipt.receipt_id if receipt is not None else None, + "verified": False, + "problems": "; ".join(problems), + }, + ) + if receipt is None: + return ReleaseReadinessCheck( + code="provider_acceptance", + status="failed", + blocking=True, + message="Production requires a valid read-only shared-staging acceptance receipt.", + evidence_scope="signed_shared_staging_acceptance_receipt", + details={ + "acceptance_run_id": acceptance_run_id, + "receipt_id": None, + "verified": False, + "problems": "a signed shared-staging acceptance receipt is required", + }, + ) + return ReleaseReadinessCheck( + code="provider_acceptance", + status="passed", + blocking=True, + message="The signed shared-staging acceptance receipt passed offline production verification.", + evidence_scope="signed_shared_staging_acceptance_receipt", + details={ + "acceptance_run_id": receipt.payload.acceptance_run.id, + "receipt_id": receipt.receipt_id, + "key_id": receipt.payload.key_id, + "target_release_id": receipt.payload.target_release_id, + "target_image_digest": receipt.payload.target_image_digest, + "verified": True, + }, + ) + + def _provider_acceptance_check( *, environment: str, acceptance_run_id: str | None, - acceptance_run: AiRunAudit | None, + acceptance_run: ProviderAcceptanceRun | None, + store: AiPlatformRepository, provider_config: ProviderConfig | None, quality_rules: AiQualityRuleConfig, + legal_attestation_secret: str | None, ) -> ReleaseReadinessCheck: blocking = environment == "production" if acceptance_run is None: @@ -234,29 +430,24 @@ def _provider_acceptance_check( if acceptance_run_id else "No provider acceptance run was supplied; live provider quality is not verified." ), - evidence_scope="live_provider_audit_run", + evidence_scope="shared_staging_provider_acceptance_manifest", details={ "acceptance_run_id": acceptance_run_id, "verified": False, - "problems": "missing acceptance run evidence", + "problems": "missing or incomplete acceptance manifest evidence", }, ) - problems: list[str] = [] - if provider_config is None or acceptance_run.provider_config_id != provider_config.id: - problems.append("run does not use the active default provider configuration") - if provider_config is None or acceptance_run.provider != provider_config.provider: - problems.append("run provider does not match the active default provider") - if provider_config is None or acceptance_run.model != provider_config.model: - problems.append("run model does not match the active default provider model") - if acceptance_run.provider_status != "succeeded" or acceptance_run.status != "succeeded": - problems.append("provider run did not succeed without fallback") - if acceptance_run.quality_status != "passed": - problems.append("provider run quality status is not passed") - if acceptance_run.citations_count < quality_rules.min_citation_count: - problems.append("provider run citation count is below policy") - if len(acceptance_run.retrieved_sources) < quality_rules.min_source_count: - problems.append("provider run retrieved-source count is below policy") + try: + problems = validate_provider_acceptance_run( + acceptance_run, + store=store, + provider_config=provider_config, + quality_rules=quality_rules, + legal_attestation_secret=legal_attestation_secret, + ) + except Exception: # Corrupt persisted evidence and missing bundled assets fail closed. + problems = ["acceptance manifest could not be validated"] if problems: return ReleaseReadinessCheck( @@ -264,14 +455,15 @@ def _provider_acceptance_check( status="failed" if blocking else "warning", blocking=blocking, message="Provider acceptance evidence does not satisfy the release policy.", - evidence_scope="live_provider_audit_run", + evidence_scope="shared_staging_provider_acceptance_manifest", details={ "acceptance_run_id": acceptance_run.id, "verified": False, - "provider_status": acceptance_run.provider_status, - "quality_status": acceptance_run.quality_status, - "citations_count": acceptance_run.citations_count, - "retrieved_source_count": len(acceptance_run.retrieved_sources), + "source_environment": acceptance_run.source_environment, + "qa_sample_count": len(acceptance_run.qa_sample_ids), + "review_sample_count": len(acceptance_run.review_sample_ids), + "knowledge_card_count": len(acceptance_run.knowledge_card_ids), + "audit_run_count": len(acceptance_run.audit_bindings), "problems": "; ".join(problems), }, ) @@ -279,15 +471,16 @@ def _provider_acceptance_check( code="provider_acceptance", status="passed", blocking=blocking, - message="The selected live provider audit run satisfies provider and citation quality checks.", - evidence_scope="live_provider_audit_run", + message="The shared-staging Provider acceptance manifest satisfies all technical and legal gates.", + evidence_scope="shared_staging_provider_acceptance_manifest", details={ "acceptance_run_id": acceptance_run.id, "verified": True, - "provider_status": acceptance_run.provider_status, - "quality_status": acceptance_run.quality_status, - "citations_count": acceptance_run.citations_count, - "retrieved_source_count": len(acceptance_run.retrieved_sources), + "source_environment": acceptance_run.source_environment, + "qa_sample_count": len(acceptance_run.qa_sample_ids), + "review_sample_count": len(acceptance_run.review_sample_ids), + "knowledge_card_count": len(acceptance_run.knowledge_card_ids), + "audit_run_count": len(acceptance_run.audit_bindings), }, ) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/release_readiness_gate.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/release_readiness_gate.py index e741c6a1..bfdefdbe 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/release_readiness_gate.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/release_readiness_gate.py @@ -17,7 +17,11 @@ def main(argv: Sequence[str] | None = None) -> int: ) parser.add_argument( "--acceptance-run-id", - help="Audited live provider run that proves the active provider met citation quality policy.", + help="Aggregate shared-staging acceptance run id. Production verifies it against the signed receipt.", + ) + parser.add_argument( + "--acceptance-receipt-file", + help="Read-only signed shared-staging receipt file used for production offline verification.", ) parser.add_argument( "--format", @@ -28,6 +32,10 @@ def main(argv: Sequence[str] | None = None) -> int: args = parser.parse_args(argv) settings = get_settings() + if args.acceptance_receipt_file: + settings = settings.model_copy( + update={"provider_acceptance_receipt_file": args.acceptance_receipt_file} + ) repository = get_repository(settings) report = evaluate_ai_release_readiness( settings=settings, diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py index e60c9ea0..25b6dd47 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py @@ -14,6 +14,7 @@ from uuid import uuid4 from pydantic import BaseModel, Field, ValidationError from sqlalchemy import bindparam, case, create_engine, delete, func, inspect as sqlalchemy_inspect, literal, literal_column, or_, select, text from sqlalchemy.engine import Engine +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool @@ -39,6 +40,7 @@ from yuqei_ai_platform_api.models import ( OcrProviderConfigModel, PromptTemplateChangeModel, PromptTemplateModel, + ProviderAcceptanceRunModel, TraceGovernancePolicyModel, WorkerHeartbeatModel, WorkerRunExecutionModel, @@ -68,6 +70,12 @@ from yuqei_ai_platform_api.persistence.prompts import ( prompt_template_change_from_model as _prompt_template_change_from_model, prompt_template_from_model as _prompt_template_from_model, ) +from yuqei_ai_platform_api.provider_acceptance import ( + ProviderAcceptanceAuditBinding, + ProviderAcceptanceRun, + ProviderAcceptanceRunCreate, + provider_acceptance_manifest_hash, +) from yuqei_ai_platform_api.secret_refs import resolve_secret_ref from yuqei_ai_platform_api.tenant_context import current_tenant_id @@ -932,6 +940,15 @@ class AiPlatformRepository(Protocol): def get_ai_run_audit(self, run_id: str) -> AiRunAudit: ... + def add_provider_acceptance_run( + self, + payload: ProviderAcceptanceRunCreate, + *, + legal_attestation_secret: str | None = None, + ) -> ProviderAcceptanceRun: ... + + def get_provider_acceptance_run(self, run_id: str) -> ProviderAcceptanceRun: ... + def run_trace_governance( self, *, @@ -995,6 +1012,7 @@ class InMemoryAiPlatformRepository: knowledge_ingestion_jobs: dict[str, KnowledgeIngestionJob] = field(default_factory=dict) knowledge_reindex_executions: list[KnowledgeReindexExecution] = field(default_factory=list) ai_run_audits: list[AiRunAudit] = field(default_factory=list) + provider_acceptance_runs: dict[str, ProviderAcceptanceRun] = field(default_factory=dict) quality_improvement_actions: dict[str, AiQualityImprovementAction] = field(default_factory=dict) quality_rule_config: AiQualityRuleConfig = field( default_factory=lambda: AiQualityRuleConfig( @@ -1953,6 +1971,50 @@ class InMemoryAiPlatformRepository: return audit raise KeyError(run_id) + def add_provider_acceptance_run( + self, + payload: ProviderAcceptanceRunCreate, + *, + legal_attestation_secret: str | None = None, + ) -> ProviderAcceptanceRun: + from yuqei_ai_platform_api.provider_acceptance import validate_provider_acceptance_run + + tenant_id = current_tenant_id() + provider_config = self.get_default_provider_config() + problems = validate_provider_acceptance_run( + payload, + store=self, + provider_config=provider_config, + quality_rules=self.get_quality_rule_config(), + legal_attestation_secret=legal_attestation_secret, + tenant_id=tenant_id, + ) + if problems: + raise ValueError("Provider acceptance manifest validation failed: " + "; ".join(problems)) + run_id = _provider_acceptance_run_id(tenant_id, payload) + manifest_hash = _provider_acceptance_manifest_hash(tenant_id, payload) + with self._lock: + existing = self.provider_acceptance_runs.get(run_id) + if existing is not None and existing.tenant_id == tenant_id: + return existing + run = ProviderAcceptanceRun( + id=run_id, + tenant_id=tenant_id, + acceptance_manifest_sha256=manifest_hash, + created_at=datetime.now(UTC), + **payload.model_dump(), + ) + self.provider_acceptance_runs[run.id] = run + return run + + def get_provider_acceptance_run(self, run_id: str) -> ProviderAcceptanceRun: + tenant_id = current_tenant_id() + with self._lock: + run = self.provider_acceptance_runs.get(run_id) + if run is not None and run.tenant_id == tenant_id: + return run + raise KeyError(run_id) + def run_trace_governance( self, *, @@ -3298,6 +3360,86 @@ class SqlAlchemyAiPlatformRepository: def get_ai_run_audit(self, run_id: str) -> AiRunAudit: return self._audit_repository().get_ai_run_audit(run_id) + def add_provider_acceptance_run( + self, + payload: ProviderAcceptanceRunCreate, + *, + legal_attestation_secret: str | None = None, + ) -> ProviderAcceptanceRun: + from yuqei_ai_platform_api.provider_acceptance import validate_provider_acceptance_run + + tenant_id = current_tenant_id() + provider_config = self.get_default_provider_config() + problems = validate_provider_acceptance_run( + payload, + store=self, + provider_config=provider_config, + quality_rules=self.get_quality_rule_config(), + legal_attestation_secret=legal_attestation_secret, + tenant_id=tenant_id, + ) + if problems: + raise ValueError("Provider acceptance manifest validation failed: " + "; ".join(problems)) + run_id = _provider_acceptance_run_id(tenant_id, payload) + manifest_hash = _provider_acceptance_manifest_hash(tenant_id, payload) + try: + with self._session_factory.begin() as session: + existing = session.get(ProviderAcceptanceRunModel, run_id) + if existing is not None: + if existing.tenant_id != tenant_id: + raise KeyError(run_id) + return _provider_acceptance_run_from_model(existing) + row = ProviderAcceptanceRunModel( + id=run_id, + tenant_id=tenant_id, + source_environment=payload.source_environment, + acceptance_policy_version=payload.acceptance_policy_version, + provider_config_id=payload.provider_config_id, + provider=payload.provider, + model=payload.model, + qa_sample_ids_json=_encode_json_list(payload.qa_sample_ids), + review_sample_ids_json=_encode_json_list(payload.review_sample_ids), + knowledge_card_ids_json=_encode_json_list(payload.knowledge_card_ids), + audit_bindings_json=_encode_json_list( + [binding.model_dump() for binding in payload.audit_bindings] + ), + suite_manifest_sha256=payload.suite_manifest_sha256, + knowledge_manifest_sha256=payload.knowledge_manifest_sha256, + legal_approval_status=payload.legal_approval_status, + legal_reviewer=payload.legal_reviewer, + legal_approved_at=payload.legal_approved_at, + legal_approval_evidence_ref=payload.legal_approval_evidence_ref, + legal_approval_evidence_sha256=payload.legal_approval_evidence_sha256, + legal_approval_attestation=payload.legal_approval_attestation, + technical_package_sha256=payload.technical_package_sha256, + legal_approval_id=payload.legal_approval_id, + legal_approval_key_id=payload.legal_approval_key_id, + legal_approval_signature=payload.legal_approval_signature, + legal_approval_approver_id=payload.legal_approval_approver_id, + legal_approval_role=payload.legal_approval_role, + legal_approval_organization=payload.legal_approval_organization, + legal_approval_expires_at=payload.legal_approval_expires_at, + acceptance_manifest_sha256=manifest_hash, + created_at=datetime.now(UTC), + ) + session.add(row) + session.flush() + return _provider_acceptance_run_from_model(row) + except IntegrityError: + with self._session_factory() as session: + existing = session.get(ProviderAcceptanceRunModel, run_id) + if existing is None or existing.tenant_id != tenant_id: + raise + return _provider_acceptance_run_from_model(existing) + + def get_provider_acceptance_run(self, run_id: str) -> ProviderAcceptanceRun: + tenant_id = current_tenant_id() + with self._session_factory() as session: + row = session.get(ProviderAcceptanceRunModel, run_id) + if row is None or row.tenant_id != tenant_id: + raise KeyError(run_id) + return _provider_acceptance_run_from_model(row) + def run_trace_governance( self, *, @@ -3738,6 +3880,63 @@ def _ai_run_audit_from_model(row: AiRunAuditModel) -> AiRunAudit: ) +def _provider_acceptance_run_from_model( + row: ProviderAcceptanceRunModel, +) -> ProviderAcceptanceRun: + return ProviderAcceptanceRun( + id=row.id, + tenant_id=row.tenant_id, + source_environment=row.source_environment, + acceptance_policy_version=row.acceptance_policy_version, + provider_config_id=row.provider_config_id, + provider=row.provider, + model=row.model, + qa_sample_ids=[str(item) for item in _decode_json_list(row.qa_sample_ids_json)], + review_sample_ids=[str(item) for item in _decode_json_list(row.review_sample_ids_json)], + knowledge_card_ids=[str(item) for item in _decode_json_list(row.knowledge_card_ids_json)], + audit_bindings=[ + ProviderAcceptanceAuditBinding.model_validate(item) + for item in _decode_json_list(row.audit_bindings_json) + ], + suite_manifest_sha256=row.suite_manifest_sha256, + knowledge_manifest_sha256=row.knowledge_manifest_sha256, + legal_approval_status=row.legal_approval_status, + legal_reviewer=row.legal_reviewer, + legal_approved_at=_ensure_utc(row.legal_approved_at), + legal_approval_evidence_ref=row.legal_approval_evidence_ref, + legal_approval_evidence_sha256=row.legal_approval_evidence_sha256, + legal_approval_attestation=row.legal_approval_attestation, + technical_package_sha256=row.technical_package_sha256, + legal_approval_id=row.legal_approval_id, + legal_approval_key_id=row.legal_approval_key_id, + legal_approval_signature=row.legal_approval_signature, + legal_approval_approver_id=row.legal_approval_approver_id, + legal_approval_role=row.legal_approval_role, + legal_approval_organization=row.legal_approval_organization, + legal_approval_expires_at=( + _ensure_utc(row.legal_approval_expires_at) + if row.legal_approval_expires_at + else None + ), + acceptance_manifest_sha256=row.acceptance_manifest_sha256, + created_at=_ensure_utc(row.created_at), + ) + + +def _provider_acceptance_run_id( + tenant_id: str, + payload: ProviderAcceptanceRunCreate, +) -> str: + return f"provider-acceptance-{_provider_acceptance_manifest_hash(tenant_id, payload)[:24]}" + + +def _provider_acceptance_manifest_hash( + tenant_id: str, + payload: ProviderAcceptanceRunCreate, +) -> str: + return provider_acceptance_manifest_hash(tenant_id, payload) + + def _quality_label_from_json_item(item: object) -> AiQualityLabel: if isinstance(item, dict): payload = dict(item) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/schema_check.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/schema_check.py index fa4406ae..af0df7ca 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/schema_check.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/schema_check.py @@ -36,6 +36,7 @@ CORE_TABLES = ( "knowledge_reindex_executions", "knowledge_ingestion_jobs", "ai_run_audits", + "provider_acceptance_runs", "ai_quality_improvement_actions", ) TENANT_COLUMNS_BY_TABLE = { @@ -45,6 +46,7 @@ TENANT_COLUMNS_BY_TABLE = { "knowledge_reindex_executions": ("tenant_id",), "knowledge_ingestion_jobs": ("tenant_id",), "ai_run_audits": ("tenant_id",), + "provider_acceptance_runs": ("tenant_id",), "ai_quality_improvement_actions": ("tenant_id",), "trace_governance_policies": ("tenant_id",), "security_access_events": ("tenant_id",), @@ -302,7 +304,7 @@ def _check_provider_runtime( problems: list[str] = [] if provider_call_mode != "enabled": problems.append("provider_call_mode must be enabled") - if provider_fallback_enabled is not False: + if environment == "production" and provider_fallback_enabled is not False: problems.append("provider fallback must be disabled") if default_provider_config is None: problems.append("an enabled default provider is required") diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_anti_abuse.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_anti_abuse.py index ae885ac5..2350417c 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_anti_abuse.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_anti_abuse.py @@ -14,7 +14,7 @@ def make_settings(**overrides) -> AiPlatformSettings: values = { "environment": "test", "repository_backend": "memory", - "anti_abuse_hash_secret": "test-ai-platform-anti-abuse-secret-0001", + "anti_abuse_hash_secret": "test-ai-platform-anti-abuse-secret-0001", # pragma: allowlist secret } values.update(overrides) return AiPlatformSettings(**values) @@ -35,8 +35,14 @@ def test_ai_platform_anti_abuse_observe_marks_sensitive_responses() -> None: def test_ai_platform_anti_abuse_enforce_blocks_after_policy_limit() -> None: client = TestClient(create_app(make_settings(anti_abuse_mode="enforce", redis_url="redis://localhost:6379/1"))) - last_response = None - for _ in range(12): + first_responses = [ + client.get("/api/v1/audit/ai-runs/run-missing", headers={"X-User-Id": "user-1"}) + for _ in range(18) + ] + assert all(response.status_code == 404 for response in first_responses) + + last_response = first_responses[-1] + for _ in range(13): last_response = client.get("/api/v1/audit/ai-runs/run-missing", headers={"X-User-Id": "user-1"}) assert last_response is not None diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_health.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_health.py index 988092d7..20d11717 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_health.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_health.py @@ -2,6 +2,8 @@ from fastapi.testclient import TestClient from yuqei_ai_platform_api.config import AiPlatformSettings from yuqei_ai_platform_api.main import create_app +from yuqei_ai_platform_api.repository import InMemoryAiPlatformRepository, ProviderConfigCreate +from yuqei_ai_platform_api.schema_check import _check_provider_runtime def test_health_endpoint_returns_ai_platform_status() -> None: @@ -89,7 +91,7 @@ def test_runtime_schema_check_warns_for_local_sqlite_auto_created_schema(tmp_pat assert checks["alembic_version"]["status"] == "warning" assert checks["core_tables"]["status"] == "ok" assert checks["tenant_columns"]["status"] == "ok" - assert checks["tenant_columns"]["details"]["checked_tables"] == 10 + assert checks["tenant_columns"]["details"]["checked_tables"] == 11 assert checks["trace_governance_columns"]["status"] == "ok" assert checks["trace_governance_columns"]["details"]["checked_columns"] == 11 assert checks["secret_ref_columns"]["status"] == "ok" @@ -123,6 +125,8 @@ def test_runtime_schema_check_errors_for_missing_core_tables(tmp_path) -> None: assert checks["pgvector_schema"]["status"] == "error" assert "knowledge_documents" in checks["core_tables"]["details"]["missing_tables"] assert "knowledge_documents.tenant_id" in checks["tenant_columns"]["details"]["missing_columns"] + assert "provider_acceptance_runs" in checks["core_tables"]["details"]["missing_tables"] + assert "provider_acceptance_runs.tenant_id" in checks["tenant_columns"]["details"]["missing_columns"] assert "ai_run_audits.redaction_status" in checks["trace_governance_columns"]["details"]["missing_columns"] assert "trace_governance_policies.enabled" in checks["trace_governance_columns"]["details"]["missing_columns"] assert "ai_provider_configs.api_key_secret_ref" in checks["secret_ref_columns"]["details"]["missing_columns"] @@ -150,3 +154,28 @@ def test_production_schema_check_rejects_disabled_or_unconfigured_provider(tmp_p assert "provider_call_mode must be enabled" in checks["provider_runtime"]["details"]["problems"] assert "provider fallback must be disabled" in checks["provider_runtime"]["details"]["problems"] assert checks["service_auth"]["status"] == "error" + + +def test_staging_schema_check_allows_explicit_non_production_fallback() -> None: + repository = InMemoryAiPlatformRepository() + repository.seed_defaults() + repository.upsert_provider_config( + ProviderConfigCreate( + provider="openai-compatible", + model="runtime-selected-model", + display_name="Staging provider", + base_url="https://provider.invalid/v1", + api_key_secret_ref="env:AI_PROVIDER_STAGING_TEST_KEY", # pragma: allowlist secret + enabled=True, + is_default=True, + ) + ) + check = _check_provider_runtime( + environment="staging", + provider_call_mode="enabled", + provider_fallback_enabled=True, + default_provider_config=repository.get_default_provider_config(), + ) + + assert check.status == "ok" + assert check.details["provider_fallback_enabled"] is True diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_legal_evidence.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_legal_evidence.py new file mode 100644 index 00000000..a22edc65 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_legal_evidence.py @@ -0,0 +1,84 @@ +import hashlib +import socket + +import pytest + +from yuqei_ai_platform_api.legal_evidence import verify_legal_evidence + + +def test_evidence_fetcher_recomputes_real_content_hash() -> None: + content = b"named legal approval evidence" + digest = hashlib.sha256(content).hexdigest() + calls = [] + + def fetcher(uri: str, max_bytes: int) -> bytes: + calls.append((uri, max_bytes)) + return content + + result = verify_legal_evidence( + "s3://legal-bucket/approval.json", + digest, + fetcher=fetcher, + ) + + assert result.sha256 == digest + assert result.size_bytes == len(content) + assert calls == [("s3://legal-bucket/approval.json", 10 * 1024 * 1024)] + + +def test_evidence_hash_mismatch_and_size_limit_fail_closed() -> None: + content = b"actual" + with pytest.raises(ValueError, match="content hash"): + verify_legal_evidence( + "s3://legal-bucket/approval.json", + "0" * 64, + fetcher=lambda _uri, _limit: content, + ) + with pytest.raises(ValueError, match="size limit"): + verify_legal_evidence( + "s3://legal-bucket/approval.json", + hashlib.sha256(content).hexdigest(), + fetcher=lambda _uri, _limit: b"0123456789", + max_bytes=5, + ) + + +@pytest.mark.parametrize( + "uri", + [ + "http://legal.example/approval.json", + "https://user:pass@legal.example/approval.json", # pragma: allowlist secret + "https://legal.example/approval.json?token=secret", + "https://legal.example/approval.json#fragment", + "https://127.0.0.1/approval.json", + "https://10.0.0.4/approval.json", + ], +) +def test_evidence_uri_boundaries_fail_closed(uri: str) -> None: + with pytest.raises(ValueError): + verify_legal_evidence( + uri, + "1" * 64, + fetcher=lambda _uri, _limit: b"never", + allowed_hosts={"legal.example"}, + ) + + +def test_https_private_dns_target_is_rejected(monkeypatch) -> None: + monkeypatch.setattr( + socket, + "getaddrinfo", + lambda *_args, **_kwargs: [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.168.1.10", 443))], + ) + with pytest.raises(ValueError, match="private address"): + verify_legal_evidence( + "https://legal.example/approval.json", + "1" * 64, + fetcher=lambda _uri, _limit: b"never", + allowed_hosts={"legal.example"}, + ) + + +def test_object_storage_requires_explicit_fetcher() -> None: + with pytest.raises(ValueError, match="configured fetcher"): + verify_legal_evidence("s3://legal-bucket/approval.json", "1" * 64) diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_acceptance_receipt.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_acceptance_receipt.py new file mode 100644 index 00000000..f3508a1e --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_acceptance_receipt.py @@ -0,0 +1,495 @@ +import hashlib +from datetime import UTC, datetime, timedelta + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +import yuqei_ai_platform_api.provider_acceptance as provider_acceptance +from yuqei_ai_platform_api.config import AiPlatformSettings +from yuqei_ai_platform_api.provider_acceptance import ( + ACCEPTANCE_POLICY_VERSION, + ProviderAcceptanceAuditBinding, + ProviderAcceptanceRun, + ProviderAcceptanceRunCreate, + expected_acceptance_inventory, + provider_acceptance_attestation, + provider_audit_snapshot_hash, +) +from yuqei_ai_platform_api.provider_acceptance_receipt import ( + ProviderAcceptancePublicKey, + ProviderAcceptancePublicKeyring, + SignedProviderAcceptanceReceipt, + build_receipt_payload, + sign_receipt_payload, + verify_receipt, +) +from yuqei_ai_platform_api.release_readiness import evaluate_ai_release_readiness +from yuqei_ai_platform_api.repository import ( + AiRunAudit, + InMemoryAiPlatformRepository, + ProviderConfigCreate, + RetrievalSource, +) + + +LEGAL_SECRET = "independent-legal-attestation-secret-for-receipt-tests" # pragma: allowlist secret +RELEASE_ID = "release-2026.08.10" +IMAGE_DIGEST = "sha256:" + "2" * 64 +KEY_ID = "staging-acceptance-2026q3" + + +class ReadCountingRepository(InMemoryAiPlatformRepository): + acceptance_reads: int = 0 + audit_reads: int = 0 + + def get_provider_acceptance_run(self, run_id: str): + self.acceptance_reads += 1 + return super().get_provider_acceptance_run(run_id) + + def get_ai_run_audit(self, run_id: str): + self.audit_reads += 1 + return super().get_ai_run_audit(run_id) + + +def _settings(*, release_id: str = RELEASE_ID) -> AiPlatformSettings: + return AiPlatformSettings( + environment="production", + provider_call_mode="enabled", + provider_fallback_enabled=False, + provider_api_key="configured-but-not-called", # pragma: allowlist secret + provider_wire_api="chat_completions", + release_id=release_id, + release_image_digest=IMAGE_DIGEST, + ) + + +def _repository(repository_type=InMemoryAiPlatformRepository): + repository = repository_type() + repository.seed_defaults() + provider = repository.upsert_provider_config( + ProviderConfigCreate( + provider="openai-compatible", + model="legal-model-v1", + display_name="Production legal model", + base_url="https://provider.invalid/v1", + temperature=0.2, + max_tokens=4096, + enabled=True, + is_default=True, + ) + ) + return repository, provider + + +def _acceptance_run(repository: InMemoryAiPlatformRepository, provider_config_id: str) -> ProviderAcceptanceRun: + inventory = expected_acceptance_inventory() + qa_ids = list(inventory["qa_sample_ids"]) + review_ids = list(inventory["review_sample_ids"]) + sources = [ + RetrievalSource( + chunk_id=f"chunk-{index}", + title=f"Source {index}", + source_type="law", + reference=f"LAW-{index}", + ) + for index in range(1, 3) + ] + bindings: list[ProviderAcceptanceAuditBinding] = [] + for index, sample_id in enumerate(qa_ids + review_ids): + suite = "legal_qa" if index < len(qa_ids) else "contract_review" + audit_id = f"provider-receipt-audit-{index + 1:02d}" + audit = repository.add_ai_run_audit( + AiRunAudit( + id=audit_id, + operation=suite, + question=provider_acceptance._expected_sample_questions()[(suite, sample_id)], + provider_config_id=provider_config_id, + provider="openai-compatible", + model="legal-model-v1", + provider_status="succeeded", + citations_count=2, + retrieved_sources=sources, + total_tokens=100, + quality_status="passed", + quality_score=100, + status="succeeded", + ) + ) + bindings.append( + ProviderAcceptanceAuditBinding( + suite=suite, + sample_id=sample_id, + audit_run_id=audit.id, + audit_snapshot_sha256=provider_audit_snapshot_hash(audit), + ) + ) + + manifest = ProviderAcceptanceRunCreate( + source_environment="shared_staging", + acceptance_policy_version=ACCEPTANCE_POLICY_VERSION, + provider_config_id=provider_config_id, + provider="openai-compatible", + model="legal-model-v1", + qa_sample_ids=qa_ids, + review_sample_ids=review_ids, + knowledge_card_ids=list(inventory["knowledge_card_ids"]), + audit_bindings=bindings, + suite_manifest_sha256=str(inventory["suite_manifest_sha256"]), + knowledge_manifest_sha256=str(inventory["knowledge_manifest_sha256"]), + legal_approval_status="approved", + legal_reviewer="Li Legal", + legal_approved_at=datetime.now(UTC), + legal_approval_evidence_ref="https://evidence.invalid/provider-acceptance.json", + legal_approval_evidence_sha256=hashlib.sha256(b"named legal evidence").hexdigest(), + technical_package_sha256=hashlib.sha256(b"technical review package").hexdigest(), + legal_approval_id="provider-legal-approval-" + "1" * 24, + legal_approval_key_id="named-legal-2026q3", + legal_approval_signature="A" * 86, + legal_approval_approver_id="li.legal@example.invalid", + legal_approval_role="Legal Counsel", + legal_approval_organization="Yuqei Legal", + legal_approval_expires_at=datetime.now(UTC) + timedelta(days=1), + ) + manifest = manifest.model_copy( + update={ + "legal_approval_attestation": provider_acceptance_attestation( + "default", + manifest, + LEGAL_SECRET, + ) + } + ) + run_id = repository.add_provider_acceptance_run( + manifest, + legal_attestation_secret=LEGAL_SECRET, + ).id + return repository.get_provider_acceptance_run(run_id) + + +def _key_material(now: datetime, *, key_id: str = KEY_ID, revoked: bool = False): + private_key = Ed25519PrivateKey.generate() + private_pem = private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + public_pem = private_key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + key = ProviderAcceptancePublicKey( + key_id=key_id, + algorithm="Ed25519", + public_key_pem=public_pem, + not_before=now - timedelta(days=1), + not_after=now + timedelta(days=8), + revoked=revoked, + ) + return private_pem, key + + +def test_receipt_payload_rejects_legacy_attestation_without_named_approval() -> None: + repository, provider = _repository() + acceptance_run = _acceptance_run(repository, provider.id) + legacy_run = acceptance_run.model_copy( + update={ + "technical_package_sha256": None, + "legal_approval_id": None, + "legal_approval_key_id": None, + "legal_approval_signature": None, + "legal_approval_approver_id": None, + "legal_approval_role": None, + "legal_approval_organization": None, + "legal_approval_expires_at": None, + } + ) + + with pytest.raises(ValueError, match="named legal approval"): + build_receipt_payload( + acceptance_run=legacy_run, + provider_config=provider, + settings=_settings(), + quality_rules=repository.get_quality_rule_config(), + prompt_templates=repository.list_prompt_templates(), + tenant_id="default", + key_id=KEY_ID, + target_release_id=RELEASE_ID, + target_image_digest=IMAGE_DIGEST, + ) + + +def _signed_fixture( + *, + production_repository_type=ReadCountingRepository, +) -> tuple[ + SignedProviderAcceptanceReceipt, + ProviderAcceptancePublicKeyring, + bytes, + datetime, + ReadCountingRepository, + AiPlatformSettings, +]: + staging, staging_provider = _repository() + acceptance_run = _acceptance_run(staging, staging_provider.id) + production, production_provider = _repository(production_repository_type) + settings = _settings() + now = datetime.now(UTC) + timedelta(seconds=1) + private_pem, public_key = _key_material(now) + payload = build_receipt_payload( + acceptance_run=acceptance_run, + provider_config=staging_provider, + settings=settings, + quality_rules=staging.get_quality_rule_config(), + prompt_templates=staging.list_prompt_templates(), + tenant_id="default", + key_id=KEY_ID, + target_release_id=RELEASE_ID, + target_image_digest=IMAGE_DIGEST, + now=now, + ttl_seconds=3600, + ) + receipt = sign_receipt_payload(payload, private_pem) + assert production_provider.id != staging_provider.id + return ( + receipt, + ProviderAcceptancePublicKeyring(version=1, keys=[public_key]), + private_pem, + now, + production, + settings, + ) + + +def _verify( + receipt: SignedProviderAcceptanceReceipt, + keyring: ProviderAcceptancePublicKeyring, + repository: InMemoryAiPlatformRepository, + settings: AiPlatformSettings, + *, + now: datetime, +) -> list[str]: + return verify_receipt( + receipt, + keyring=keyring, + tenant_id="default", + target_release_id=settings.release_id, + target_image_digest=settings.release_image_digest, + provider_config=repository.get_default_provider_config(), + settings=settings, + quality_rules=repository.get_quality_rule_config(), + prompt_templates=repository.list_prompt_templates(), + now=now, + ) + + +def test_production_accepts_signed_receipt_without_acceptance_or_audit_reads() -> None: + receipt, keyring, _, now, repository, settings = _signed_fixture() + repository.acceptance_reads = 0 + repository.audit_reads = 0 + + first = evaluate_ai_release_readiness( + settings=settings, + store=repository, + acceptance_run_id=receipt.payload.acceptance_run.id, + acceptance_receipt=receipt, + acceptance_keyring=keyring, + ) + second = evaluate_ai_release_readiness( + settings=settings, + store=repository, + acceptance_run_id=receipt.payload.acceptance_run.id, + acceptance_receipt=receipt, + acceptance_keyring=keyring, + ) + + assert first.status == second.status == "ready" + assert first.provider_acceptance_verified is second.provider_acceptance_verified is True + assert first.promotion_diagnostics.verification_mode == "offline_read_only" + assert first.promotion_diagnostics.verified is True + assert first.promotion_diagnostics.image_digest == IMAGE_DIGEST + assert first.promotion_diagnostics.receipt_id == receipt.receipt_id + assert first.promotion_diagnostics.key_id == KEY_ID + serialized_diagnostics = first.promotion_diagnostics.model_dump_json() + assert "configured-but-not-called" not in serialized_diagnostics + assert "provider_acceptance_receipt_file" not in serialized_diagnostics + assert repository.provider_acceptance_runs == {} + assert repository.ai_run_audits == [] + assert repository.acceptance_reads == 0 + assert repository.audit_reads == 0 + assert _verify(receipt, keyring, repository, settings, now=now) == [] + + +def test_production_loads_pinned_receipt_and_keyring_files_without_acceptance_db( + tmp_path, +) -> None: + receipt, keyring, _, _, repository, settings = _signed_fixture() + receipt_path = tmp_path / "provider-acceptance-receipt.json" + keyring_path = tmp_path / "provider-acceptance-keyring.json" + receipt_bytes = (receipt.model_dump_json() + "\n").encode("utf-8") + receipt_path.write_bytes(receipt_bytes) + keyring_path.write_text(keyring.model_dump_json() + "\n", encoding="utf-8") + file_settings = settings.model_copy( + update={ + "provider_acceptance_receipt_file": str(receipt_path), + "provider_acceptance_trusted_keys_file": str(keyring_path), + "provider_acceptance_expected_receipt_sha256": hashlib.sha256(receipt_bytes).hexdigest(), + } + ) + repository.acceptance_reads = 0 + repository.audit_reads = 0 + + report = evaluate_ai_release_readiness(settings=file_settings, store=repository) + + assert report.status == "ready" + assert report.provider_acceptance_verified is True + assert repository.provider_acceptance_runs == {} + assert repository.ai_run_audits == [] + assert repository.acceptance_reads == 0 + assert repository.audit_reads == 0 + + +def test_production_rejects_receipt_file_without_deployment_hash_pin(tmp_path) -> None: + receipt, keyring, _, _, repository, settings = _signed_fixture() + receipt_path = tmp_path / "provider-acceptance-receipt.json" + keyring_path = tmp_path / "provider-acceptance-keyring.json" + receipt_path.write_text(receipt.model_dump_json() + "\n", encoding="utf-8") + keyring_path.write_text(keyring.model_dump_json() + "\n", encoding="utf-8") + file_settings = settings.model_copy( + update={ + "provider_acceptance_receipt_file": str(receipt_path), + "provider_acceptance_trusted_keys_file": str(keyring_path), + "provider_acceptance_expected_receipt_sha256": None, + } + ) + + report = evaluate_ai_release_readiness(settings=file_settings, store=repository) + + assert report.status == "blocked" + check = next(item for item in report.checks if item.code == "provider_acceptance") + assert "deployment-pinned receipt SHA-256 is required" in check.details["problems"] + + +def test_receipt_payload_tampering_breaks_signature() -> None: + receipt, keyring, _, now, repository, settings = _signed_fixture() + tampered_runtime = receipt.payload.provider_runtime.model_copy(update={"model": "other-model"}) + tampered = receipt.model_copy( + update={"payload": receipt.payload.model_copy(update={"provider_runtime": tampered_runtime})} + ) + + assert "receipt signature is invalid" in _verify( + tampered, + keyring, + repository, + settings, + now=now, + ) + + +@pytest.mark.parametrize( + ("field", "value", "problem"), + [ + ("tenant_id", "other-tenant", "receipt tenant does not match the active tenant"), + ("target_release_id", "other-release", "receipt target release does not match the active release"), + ("target_image_digest", "sha256:" + "3" * 64, "receipt target image digest does not match the active image"), + ], +) +def test_resigned_receipt_cannot_cross_tenant_or_release( + field: str, + value: str, + problem: str, +) -> None: + receipt, keyring, private_pem, now, repository, settings = _signed_fixture() + resigned = sign_receipt_payload(receipt.payload.model_copy(update={field: value}), private_pem) + + assert problem in _verify(resigned, keyring, repository, settings, now=now) + + +def test_resigned_receipt_cannot_cross_provider_runtime() -> None: + receipt, keyring, private_pem, now, repository, settings = _signed_fixture() + changed_runtime = receipt.payload.provider_runtime.model_copy( + update={"base_url": "https://different-provider.invalid/v1"} + ) + resigned = sign_receipt_payload( + receipt.payload.model_copy(update={"provider_runtime": changed_runtime}), + private_pem, + ) + + problems = _verify(resigned, keyring, repository, settings, now=now) + assert "Provider runtime fingerprint does not match the accepted runtime" in problems + + +@pytest.mark.parametrize( + ("issued_delta", "expires_delta", "problem"), + [ + (timedelta(hours=1), timedelta(hours=2), "receipt was issued in the future"), + (timedelta(hours=-2), timedelta(hours=-1), "receipt is expired"), + (timedelta(hours=-1), timedelta(days=8), "receipt lifetime exceeds policy"), + ], +) +def test_receipt_time_window_fails_closed( + issued_delta: timedelta, + expires_delta: timedelta, + problem: str, +) -> None: + receipt, keyring, private_pem, now, repository, settings = _signed_fixture() + payload = receipt.payload.model_copy( + update={ + "issued_at": now + issued_delta, + "not_before": now + issued_delta, + "expires_at": now + expires_delta, + } + ) + resigned = sign_receipt_payload(payload, private_pem) + + assert problem in _verify(resigned, keyring, repository, settings, now=now) + + +def test_key_rotation_accepts_new_key_and_revocation_rejects_old_key() -> None: + receipt, _, old_private, now, repository, settings = _signed_fixture() + new_private, new_key = _key_material(now, key_id="staging-acceptance-2026q4") + old_public_pem = serialization.load_pem_private_key(old_private, password=None).public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + old_key = ProviderAcceptancePublicKey( + key_id=KEY_ID, + algorithm="Ed25519", + public_key_pem=old_public_pem, + not_before=now - timedelta(days=1), + not_after=now + timedelta(days=8), + revoked=True, + ) + rotated_payload = receipt.payload.model_copy(update={"key_id": new_key.key_id}) + new_receipt = sign_receipt_payload(rotated_payload, new_private) + keyring = ProviderAcceptancePublicKeyring(version=1, keys=[old_key, new_key]) + + assert _verify(new_receipt, keyring, repository, settings, now=now) == [] + assert "receipt signing key is revoked" in _verify( + receipt, + keyring, + repository, + settings, + now=now, + ) + + +def test_invalid_receipt_never_falls_back_to_local_acceptance_database() -> None: + receipt, keyring, _, _, repository, settings = _signed_fixture() + repository.provider_acceptance_runs[receipt.payload.acceptance_run.id] = receipt.payload.acceptance_run + repository.acceptance_reads = 0 + repository.audit_reads = 0 + invalid = receipt.model_copy(update={"signature": "A" * 86}) + + report = evaluate_ai_release_readiness( + settings=settings, + store=repository, + acceptance_run_id=receipt.payload.acceptance_run.id, + acceptance_receipt=invalid, + acceptance_keyring=keyring, + ) + + assert report.status == "blocked" + assert report.provider_acceptance_verified is False + assert repository.acceptance_reads == 0 + assert repository.audit_reads == 0 diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_acceptance_registration.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_acceptance_registration.py new file mode 100644 index 00000000..073fe9df --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_acceptance_registration.py @@ -0,0 +1,280 @@ +import hashlib +from datetime import UTC, datetime, timedelta + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from fastapi.testclient import TestClient +from sqlalchemy import text + +import yuqei_ai_platform_api.api.quality as quality_api +import yuqei_ai_platform_api.provider_acceptance as provider_acceptance +from yuqei_ai_platform_api.config import AiPlatformSettings +from yuqei_ai_platform_api.legal_evidence import LegalEvidenceVerification +from yuqei_ai_platform_api.main import create_app +from yuqei_ai_platform_api.provider_acceptance import expected_acceptance_inventory +from yuqei_ai_platform_api.provider_legal_approval import ( + LegalApprovalAuditBinding, + ProviderLegalApprovalKeyring, + ProviderLegalApprovalPayload, + ProviderLegalApprovalPublicKey, + sign_legal_approval, +) +from yuqei_ai_platform_api.repository import ( + AiRunAudit, + ProviderConfigCreate, + RetrievalSource, + SqlAlchemyAiPlatformRepository, +) + + +def _registration_fixture(tmp_path): + database_url = f"sqlite+pysqlite:///{(tmp_path / 'named-approval.sqlite3').as_posix()}" + repository = SqlAlchemyAiPlatformRepository(database_url) + repository.seed_defaults() + provider = repository.upsert_provider_config( + ProviderConfigCreate( + provider="openai-compatible", + model="legal-model-v1", + display_name="Shared staging legal model", + base_url="https://provider.example/v1", + enabled=True, + is_default=True, + ) + ) + inventory = expected_acceptance_inventory() + qa_sample_ids = list(inventory["qa_sample_ids"]) + review_sample_ids = list(inventory["review_sample_ids"]) + audit_bindings = [] + sources = [ + RetrievalSource( + chunk_id=f"chunk-{index}", + title=f"Source {index}", + source_type="law", + reference=f"LAW-{index}", + ) + for index in range(1, 3) + ] + for index, sample_id in enumerate(qa_sample_ids + review_sample_ids): + suite = "legal_qa" if index < len(qa_sample_ids) else "contract_review" + audit = repository.add_ai_run_audit( + AiRunAudit( + id=f"named-legal-audit-{index + 1:02d}", + operation=suite, + question=provider_acceptance._expected_sample_questions()[(suite, sample_id)], + provider_config_id=provider.id, + provider=provider.provider, + model=provider.model, + provider_status="succeeded", + citations_count=2, + retrieved_sources=sources, + quality_status="passed", + quality_score=100, + status="succeeded", + ) + ) + audit_bindings.append( + LegalApprovalAuditBinding( + suite=suite, + sample_id=sample_id, + audit_run_id=audit.id, + ) + ) + + approved_at = datetime.now(UTC) + evidence = b"immutable named legal approval evidence" + technical_package = b"deterministic provider acceptance review package" + private_key = Ed25519PrivateKey.generate() + private_key_pem = private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + public_key_pem = private_key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + key_id = "legal-approver-2026q3" + approval_payload = ProviderLegalApprovalPayload( + approval_version="provider-legal-approval-v1", + algorithm="Ed25519", + key_id=key_id, + decision="approved", + tenant_id="default", + approver_id="li.legal@example.com", + reviewer="Li Legal", + role="Named legal counsel", + organization="Yuqei Legal", + approved_at=approved_at, + expires_at=approved_at + timedelta(days=7), + provider_config_id=provider.id, + provider=provider.provider, + model=provider.model, + qa_sample_ids=qa_sample_ids, + review_sample_ids=review_sample_ids, + knowledge_card_ids=list(inventory["knowledge_card_ids"]), + audit_bindings=audit_bindings, + suite_manifest_sha256=str(inventory["suite_manifest_sha256"]), + knowledge_manifest_sha256=str(inventory["knowledge_manifest_sha256"]), + technical_evidence_sha256=hashlib.sha256(technical_package).hexdigest(), + evidence_ref="https://evidence.example/provider/legal-approval.json", + evidence_sha256=hashlib.sha256(evidence).hexdigest(), + ) + approval = sign_legal_approval(approval_payload, private_key_pem) + keyring = ProviderLegalApprovalKeyring( + version=1, + keys=[ + ProviderLegalApprovalPublicKey( + key_id=key_id, + algorithm="Ed25519", + public_key_pem=public_key_pem, + approver_id=approval_payload.approver_id, + reviewer=approval_payload.reviewer, + role=approval_payload.role, + organization=approval_payload.organization, + not_before=approved_at - timedelta(days=1), + not_after=approved_at + timedelta(days=8), + ) + ], + ) + keyring_path = tmp_path / "legal-keyring.json" + keyring_path.write_text(keyring.model_dump_json() + "\n", encoding="utf-8") + registration = { + "provider_config_id": provider.id, + "provider": provider.provider, + "model": provider.model, + "qa_sample_ids": qa_sample_ids, + "review_sample_ids": review_sample_ids, + "knowledge_card_ids": list(inventory["knowledge_card_ids"]), + "audit_bindings": [item.model_dump(mode="json") for item in audit_bindings], + "suite_manifest_sha256": str(inventory["suite_manifest_sha256"]), + "knowledge_manifest_sha256": str(inventory["knowledge_manifest_sha256"]), + "technical_package_sha256": approval_payload.technical_evidence_sha256, + "legal_approval": approval.model_dump(mode="json"), + } + settings = AiPlatformSettings( + environment="staging", + repository_backend="sqlalchemy", + database_url=database_url, + require_service_auth=True, + service_shared_secret="s" * 48, + provider_legal_approval_trusted_keys_file=str(keyring_path), + provider_legal_evidence_allowed_hosts="evidence.example", + ) + return database_url, repository, settings, registration, evidence + + +def test_protected_registration_verifies_named_approval_and_persists_metadata( + tmp_path, + monkeypatch, +) -> None: + database_url, repository, settings, registration, evidence = _registration_fixture(tmp_path) + evidence_calls = [] + + def verify_evidence(uri, expected_sha256, **kwargs): + evidence_calls.append((uri, expected_sha256, kwargs)) + assert hashlib.sha256(evidence).hexdigest() == expected_sha256 + return LegalEvidenceVerification( + uri=uri, + sha256=expected_sha256, + size_bytes=len(evidence), + content_type="application/json", + ) + + monkeypatch.setattr(quality_api, "verify_legal_evidence", verify_evidence) + client = TestClient(create_app(settings, repository=repository)) + headers = {"X-AI-Platform-Service-Key": settings.service_shared_secret} + + response = client.post( + "/api/v1/quality/provider-acceptance-runs", + json=registration, + headers=headers, + ) + + assert response.status_code == 201, response.text + result = response.json() + assert result["legal_approval_id"] == registration["legal_approval"]["approval_id"] + assert result["legal_approval_key_id"] == "legal-approver-2026q3" + assert result["legal_approval_approver_id"] == "li.legal@example.com" + assert result["technical_package_sha256"] == registration["technical_package_sha256"] + assert result["legal_approval_attestation"] is None + assert len(evidence_calls) == 1 + assert evidence_calls[0][2]["allowed_hosts"] == {"evidence.example"} + + reopened = SqlAlchemyAiPlatformRepository(database_url, auto_create=False) + persisted = reopened.get_provider_acceptance_run(result["id"]) + assert persisted.legal_approval_signature == registration["legal_approval"]["signature"] + assert persisted.legal_approval_role == "Named legal counsel" + assert persisted.legal_approval_organization == "Yuqei Legal" + assert persisted.legal_approval_expires_at is not None + + receipt_payload = client.get( + f"/api/v1/quality/provider-acceptance-runs/{result['id']}/receipt-payload", + params={ + "key_id": "promotion-2026q3", + "target_release_id": "release-2026.08.10", + "target_image_digest": "sha256:" + "2" * 64, + }, + headers=headers, + ) + assert receipt_payload.status_code == 200, receipt_payload.text + assert receipt_payload.json()["acceptance_run"]["legal_approval_id"] == result["legal_approval_id"] + assert receipt_payload.json()["target_image_digest"] == "sha256:" + "2" * 64 + assert len(evidence_calls) == 2 + + tampered_manifest = provider_acceptance.ProviderAcceptanceRunCreate.model_validate( + persisted.model_dump( + exclude={"id", "tenant_id", "acceptance_manifest_sha256", "created_at"} + ) + ).model_copy(update={"legal_approval_signature": "A" * 86}) + tampered_hash = provider_acceptance.provider_acceptance_manifest_hash( + "default", + tampered_manifest, + ) + with reopened._engine.begin() as connection: + connection.execute( + text( + "UPDATE provider_acceptance_runs " + "SET legal_approval_signature = :signature, " + "acceptance_manifest_sha256 = :manifest_hash WHERE id = :run_id" + ), + { + "signature": tampered_manifest.legal_approval_signature, + "manifest_hash": tampered_hash, + "run_id": result["id"], + }, + ) + + tampered_receipt = client.get( + f"/api/v1/quality/provider-acceptance-runs/{result['id']}/receipt-payload", + params={ + "key_id": "promotion-2026q3", + "target_release_id": "release-2026.08.10", + "target_image_digest": "sha256:" + "2" * 64, + }, + headers=headers, + ) + assert tampered_receipt.status_code == 422 + assert "legal approval signature is invalid" in str( + tampered_receipt.json()["detail"]["problems"] + ) + + +def test_protected_registration_rejects_legacy_hmac_even_with_signed_envelope( + tmp_path, + monkeypatch, +) -> None: + _, repository, settings, registration, _ = _registration_fixture(tmp_path) + registration["legal_approval_attestation"] = "a" * 64 + monkeypatch.setattr( + quality_api, + "verify_legal_evidence", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("must not fetch evidence")), + ) + response = TestClient(create_app(settings, repository=repository)).post( + "/api/v1/quality/provider-acceptance-runs", + json=registration, + headers={"X-AI-Platform-Service-Key": settings.service_shared_secret}, + ) + + assert response.status_code == 422 + assert "Legacy HMAC" in response.json()["detail"] diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py index 771942e6..4cfa0271 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py @@ -1,4 +1,8 @@ import json +import time +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread import httpx from fastapi.testclient import TestClient @@ -21,6 +25,55 @@ from yuqei_ai_platform_api.repository import ( ) +@contextmanager +def _slow_provider_endpoint(*, delay_seconds: float = 0.2): + class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: # noqa: N802 + content_length = int(self.headers.get("Content-Length", "0")) + self.rfile.read(content_length) + time.sleep(delay_seconds) + body = json.dumps( + {"choices": [{"message": {"content": "late response"}}]} + ).encode("utf-8") + try: + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + return + + def log_message(self, _format: str, *_args: object) -> None: + return + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}/v1" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def _provider_repository(base_url: str) -> InMemoryAiPlatformRepository: + repository = InMemoryAiPlatformRepository() + repository.seed_defaults() + repository.upsert_provider_config( + ProviderConfigCreate( + provider="dynamic-openai-compatible", + model="runtime-selected-model", + display_name="Runtime selected provider", + base_url=base_url, + enabled=True, + is_default=True, + ) + ) + return repository + + def test_legal_qa_uses_provider_answer_when_enabled(monkeypatch) -> None: repository = InMemoryAiPlatformRepository() repository.seed_defaults() @@ -155,6 +208,150 @@ def test_provider_failure_falls_back_to_rule_answer_and_audit_status() -> None: assert {"citation_present", "fallback_used", "provider_failed", "token_missing"} <= quality_codes +def test_real_loopback_timeout_uses_non_production_fallback_with_redacted_evidence( + monkeypatch, +) -> None: + monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost") + monkeypatch.setenv("no_proxy", "127.0.0.1,localhost") + with _slow_provider_endpoint() as base_url: + repository = _provider_repository(base_url) + client = TestClient( + create_app( + AiPlatformSettings( + environment="staging", + provider_call_mode="enabled", + provider_api_key="isolated-test-key", # pragma: allowlist secret + provider_request_timeout_seconds=0.05, + provider_fallback_enabled=True, + ), + repository=repository, + ) + ) + + response = client.post( + "/api/v1/legal/qa", + json={"question": "What evidence should a contract retain?"}, + ) + + assert response.status_code == 200 + assert response.json()["answer"] + audit = client.get("/api/v1/audit/ai-runs").json()[0] + assert audit["status"] == "fallback" + assert audit["provider_status"] == "failed" + assert audit["provider_error"] == "Provider request timed out." + serialized = json.dumps(audit) + assert base_url not in serialized + assert "isolated-test-key" not in serialized # pragma: allowlist secret + + detail = client.get(f"/api/v1/audit/ai-runs/{audit['id']}").json() + quality_codes = {label["code"] for label in detail["quality_labels"]} + assert {"fallback_used", "provider_failed"} <= quality_codes + + +def test_production_never_uses_fallback_even_when_flag_is_misconfigured() -> None: + repository = _provider_repository("https://provider.invalid/v1") + client = TestClient( + create_app( + AiPlatformSettings( + environment="production", + provider_call_mode="enabled", + provider_api_key=None, + provider_fallback_enabled=True, + ), + repository=repository, + ) + ) + + response = client.post( + "/api/v1/legal/qa", + json={"question": "What evidence should a contract retain?"}, + ) + + assert response.status_code == 200 + assert response.json()["answer"] == "AI service is temporarily unavailable. Please try again later." + audit = client.get("/api/v1/audit/ai-runs").json()[0] + assert audit["status"] == "failed" + assert audit["provider_status"] == "failed" + assert audit["provider_error"] == "Missing provider API key." + + +def test_production_contract_review_never_uses_fallback_when_flag_is_misconfigured() -> None: + repository = _provider_repository("https://provider.invalid/v1") + client = TestClient( + create_app( + AiPlatformSettings( + environment="production", + provider_call_mode="enabled", + provider_api_key=None, + provider_fallback_enabled=True, + ), + repository=repository, + ) + ) + + response = client.post( + "/api/v1/legal/contract-review", + json={ + "contract_title": "Service agreement", + "contract_text": "The customer pays after acceptance.", + "contract_type": "service", + "review_focus": "Payment and acceptance", + }, + ) + + assert response.status_code == 200 + assert response.json()["summary"] == "AI service is temporarily unavailable. Please try again later." + assert response.json()["risk_level"] == "unknown" + assert response.json()["risk_items"] == [] + assert response.json()["confidence"] == 0.0 + audit = client.get("/api/v1/audit/ai-runs").json()[0] + assert audit["operation"] == "contract_review" + assert audit["status"] == "failed" + assert audit["provider_status"] == "failed" + assert audit["provider_error"] == "Missing provider API key." + + +def test_production_contract_review_never_falls_back_on_invalid_provider_output( + monkeypatch, +) -> None: + monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost") + monkeypatch.setenv("no_proxy", "127.0.0.1,localhost") + with _slow_provider_endpoint(delay_seconds=0) as base_url: + repository = _provider_repository(base_url) + client = TestClient( + create_app( + AiPlatformSettings( + environment="production", + provider_call_mode="enabled", + provider_api_key="isolated-test-key", # pragma: allowlist secret + provider_fallback_enabled=True, + ), + repository=repository, + ) + ) + + response = client.post( + "/api/v1/legal/contract-review", + json={ + "contract_title": "Service agreement", + "contract_text": "The customer pays after acceptance.", + "contract_type": "service", + "review_focus": "Payment and acceptance", + }, + ) + + assert response.status_code == 200 + assert response.json()["summary"] == ( + "AI service returned an invalid structured review. Please try again later." + ) + assert response.json()["risk_level"] == "unknown" + assert response.json()["risk_items"] == [] + audit = client.get("/api/v1/audit/ai-runs").json()[0] + assert audit["status"] == "failed" + assert audit["provider_status"] == "failed" + assert audit["provider_error"] == "Provider contract review output failed structured validation." + + def test_openai_compatible_provider_extracts_chat_answer() -> None: provider_config = InMemoryAiPlatformRepository() created = provider_config.upsert_provider_config( diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_legal_approval.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_legal_approval.py new file mode 100644 index 00000000..315b16ff --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_legal_approval.py @@ -0,0 +1,212 @@ +import json +from datetime import UTC, datetime, timedelta + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from yuqei_ai_platform_api.provider_acceptance import expected_acceptance_inventory +from yuqei_ai_platform_api.provider_legal_approval import ( + LEGAL_APPROVAL_ALGORITHM, + LEGAL_APPROVAL_VERSION, + LegalApprovalAuditBinding, + ProviderLegalApprovalKeyring, + ProviderLegalApprovalPayload, + ProviderLegalApprovalPublicKey, + load_legal_approval_file, + load_legal_keyring_file, + sign_legal_approval, + verify_legal_approval, +) + + +NOW = datetime(2026, 8, 10, 8, tzinfo=UTC) +TECHNICAL_HASH = "1" * 64 + + +def _payload(**updates) -> ProviderLegalApprovalPayload: + inventory = expected_acceptance_inventory() + qa_ids = list(inventory["qa_sample_ids"]) + review_ids = list(inventory["review_sample_ids"]) + bindings = [ + LegalApprovalAuditBinding( + suite="legal_qa" if index < 10 else "contract_review", + sample_id=sample_id, + audit_run_id=f"audit-{index + 1:02d}", + ) + for index, sample_id in enumerate(qa_ids + review_ids) + ] + values = { + "approval_version": LEGAL_APPROVAL_VERSION, + "algorithm": LEGAL_APPROVAL_ALGORITHM, + "key_id": "legal-2026q3", + "decision": "approved", + "tenant_id": "default", + "approver_id": "legal-reviewer@example.com", + "reviewer": "Named Legal Reviewer", + "role": "Senior Legal Counsel", + "organization": "Yuqei Legal", + "approved_at": NOW, + "expires_at": NOW + timedelta(days=7), + "provider_config_id": "provider-config-1", + "provider": "openai-compatible", + "model": "legal-model-v1", + "qa_sample_ids": qa_ids, + "review_sample_ids": review_ids, + "knowledge_card_ids": list(inventory["knowledge_card_ids"]), + "audit_bindings": bindings, + "suite_manifest_sha256": inventory["suite_manifest_sha256"], + "knowledge_manifest_sha256": inventory["knowledge_manifest_sha256"], + "technical_evidence_sha256": TECHNICAL_HASH, + "evidence_ref": "https://legal.example.test/provider-acceptance/evidence.json", + "evidence_sha256": "2" * 64, + } + values.update(updates) + return ProviderLegalApprovalPayload.model_validate(values) + + +def _key_material(*, key_id="legal-2026q3", revoked=False): + private = Ed25519PrivateKey.generate() + private_pem = private.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + public_pem = private.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + entry = ProviderLegalApprovalPublicKey( + key_id=key_id, + algorithm=LEGAL_APPROVAL_ALGORITHM, + public_key_pem=public_pem, + approver_id="legal-reviewer@example.com", + reviewer="Named Legal Reviewer", + role="Senior Legal Counsel", + organization="Yuqei Legal", + not_before=NOW - timedelta(days=1), + not_after=NOW + timedelta(days=30), + revoked=revoked, + ) + return private_pem, entry + + +def _verify(approval, keyring, **updates): + arguments = { + "tenant_id": "default", + "provider_config_id": "provider-config-1", + "provider": "openai-compatible", + "model": "legal-model-v1", + "technical_evidence_sha256": TECHNICAL_HASH, + "now": NOW + timedelta(minutes=1), + } + arguments.update(updates) + return verify_legal_approval(approval, keyring=keyring, **arguments) + + +def test_named_legal_approval_signs_and_verifies_idempotently() -> None: + private_pem, key = _key_material() + approval = sign_legal_approval(_payload(), private_pem) + keyring = ProviderLegalApprovalKeyring(version=1, keys=[key]) + + assert _verify(approval, keyring) == [] + assert _verify(approval, keyring) == [] + assert approval.approval_id.startswith("provider-legal-approval-") + + +@pytest.mark.parametrize( + ("field", "value", "problem"), + [ + ("tenant_id", "other", "tenant"), + ("provider_config_id", "other", "Provider configuration"), + ("provider", "other", "Provider identity"), + ("model", "other", "Provider identity"), + ("technical_evidence_sha256", "3" * 64, "technical evidence"), + ], +) +def test_legal_approval_cannot_cross_scope(field, value, problem) -> None: + private_pem, key = _key_material() + approval = sign_legal_approval(_payload(), private_pem) + keyring = ProviderLegalApprovalKeyring(version=1, keys=[key]) + + assert any(problem in item for item in _verify(approval, keyring, **{field: value})) + + +def test_tampering_and_trusted_identity_mismatch_fail() -> None: + private_pem, key = _key_material() + approval = sign_legal_approval(_payload(), private_pem) + tampered = approval.model_copy( + update={"payload": approval.payload.model_copy(update={"reviewer": "Other Reviewer"})} + ) + keyring = ProviderLegalApprovalKeyring(version=1, keys=[key]) + + problems = _verify(tampered, keyring) + assert "legal approval identity does not match the trusted key owner" in problems + assert "legal approval signature is invalid" in problems + + +def test_unknown_revoked_and_rotated_keys() -> None: + old_private, old_key = _key_material() + approval = sign_legal_approval(_payload(), old_private) + new_private, new_key = _key_material(key_id="legal-2026q4") + new_approval = sign_legal_approval(_payload(key_id="legal-2026q4"), new_private) + + assert "legal approval signing key is unknown or duplicated" in _verify( + approval, + ProviderLegalApprovalKeyring(version=1, keys=[new_key]), + ) + revoked = old_key.model_copy(update={"revoked": True}) + assert "legal approval signing key is revoked" in _verify( + approval, + ProviderLegalApprovalKeyring(version=1, keys=[revoked, new_key]), + ) + assert _verify( + new_approval, + ProviderLegalApprovalKeyring(version=1, keys=[old_key, new_key]), + ) == [] + + +def test_expired_future_and_excessive_validity_fail_closed() -> None: + private_pem, key = _key_material() + keyring = ProviderLegalApprovalKeyring(version=1, keys=[key]) + + expired = sign_legal_approval( + _payload(approved_at=NOW - timedelta(days=10), expires_at=NOW - timedelta(days=1)), + private_pem, + ) + assert "legal approval is expired" in _verify(expired, keyring) + + future = sign_legal_approval( + _payload(approved_at=NOW + timedelta(hours=1), expires_at=NOW + timedelta(days=1)), + private_pem, + ) + assert "legal approval time is in the future" in _verify(future, keyring) + + with pytest.raises(ValueError, match="validity exceeds policy"): + sign_legal_approval( + _payload(expires_at=NOW + timedelta(days=31)), + private_pem, + ) + + +def test_strict_bounded_file_loaders_reject_duplicate_keys(tmp_path) -> None: + private_pem, key = _key_material() + approval = sign_legal_approval(_payload(), private_pem) + approval_path = tmp_path / "approval.json" + keyring_path = tmp_path / "keyring.json" + approval_path.write_text(approval.model_dump_json(), encoding="utf-8") + keyring_path.write_text( + ProviderLegalApprovalKeyring(version=1, keys=[key]).model_dump_json(), + encoding="utf-8", + ) + + assert load_legal_approval_file(approval_path) == approval + assert load_legal_keyring_file(keyring_path).keys == [key] + approval_path.write_text('{"approval_id":"a","approval_id":"b"}', encoding="utf-8") + with pytest.raises(ValueError, match="duplicate JSON key"): + load_legal_approval_file(approval_path) + + duplicate_keyring = {"version": 1, "keys": [key.model_dump(mode="json")] * 2} + keyring_path.write_text(json.dumps(duplicate_keyring), encoding="utf-8") + with pytest.raises(ValueError, match="duplicate key ids"): + load_legal_keyring_file(keyring_path) diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_release_readiness.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_release_readiness.py index feee15b4..ba68eac5 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_release_readiness.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_release_readiness.py @@ -1,27 +1,45 @@ import json +import hashlib import os import subprocess import sys +from datetime import UTC, datetime from pathlib import Path +import pytest from fastapi.testclient import TestClient +from pydantic import ValidationError from yuqei_ai_platform_api.config import AiPlatformSettings from yuqei_ai_platform_api.main import create_app +from yuqei_ai_platform_api.provider_acceptance import ( + ACCEPTANCE_POLICY_VERSION, + ProviderAcceptanceAuditBinding, + ProviderAcceptanceRunCreate, + expected_acceptance_inventory, + provider_acceptance_attestation, + provider_audit_snapshot_hash, +) +import yuqei_ai_platform_api.provider_acceptance as provider_acceptance from yuqei_ai_platform_api.repository import ( AiRunAudit, InMemoryAiPlatformRepository, ProviderConfigCreate, RetrievalSource, + SqlAlchemyAiPlatformRepository, ) +LEGAL_ATTESTATION_SECRET = "independent-legal-attestation-secret-for-tests" # pragma: allowlist secret + + def _client( environment: str, *, provider_call_mode: str = "disabled", provider_fallback_enabled: bool = True, provider_api_key: str | None = None, + provider_legal_attestation_secret: str = LEGAL_ATTESTATION_SECRET, ) -> tuple[TestClient, InMemoryAiPlatformRepository]: repository = InMemoryAiPlatformRepository() repository.seed_defaults() @@ -30,6 +48,7 @@ def _client( provider_call_mode=provider_call_mode, provider_fallback_enabled=provider_fallback_enabled, provider_api_key=provider_api_key, + provider_legal_attestation_secret=provider_legal_attestation_secret, ) return TestClient(create_app(settings, repository=repository)), repository @@ -73,6 +92,77 @@ def _acceptance_run(provider_config_id: str) -> AiRunAudit: ) +def _acceptance_manifest(provider_config_id: str, repository: InMemoryAiPlatformRepository, *, broken: bool = False) -> str: + inventory = expected_acceptance_inventory() + qa_ids = list(inventory["qa_sample_ids"]) + review_ids = list(inventory["review_sample_ids"]) + bindings: list[ProviderAcceptanceAuditBinding] = [] + for index, sample_id in enumerate(qa_ids + review_ids): + suite = "legal_qa" if index < len(qa_ids) else "contract_review" + audit_id = f"provider-acceptance-audit-{index + 1:02d}" + expected_question = provider_acceptance._expected_sample_questions()[(suite, sample_id)] + audit = _acceptance_run(provider_config_id).model_copy( + update={ + "id": audit_id, + "operation": suite, + "question": expected_question, + "citations_count": 2, + "retrieved_sources": _acceptance_run(provider_config_id).retrieved_sources, + } + ) + if broken and index == 0: + audit = audit.model_copy(update={"provider_status": "failed", "status": "fallback", "quality_status": "needs_review"}) + audit = repository.add_ai_run_audit(audit) + bindings.append( + ProviderAcceptanceAuditBinding( + suite=suite, + sample_id=sample_id, + audit_run_id=audit_id, + audit_snapshot_sha256=provider_audit_snapshot_hash(audit), + ) + ) + manifest = ProviderAcceptanceRunCreate( + source_environment="shared_staging", + acceptance_policy_version=ACCEPTANCE_POLICY_VERSION, + provider_config_id=provider_config_id, + provider="openai-compatible", + model="legal-model-v1", + qa_sample_ids=qa_ids, + review_sample_ids=review_ids, + knowledge_card_ids=list(inventory["knowledge_card_ids"]), + audit_bindings=bindings, + suite_manifest_sha256=inventory["suite_manifest_sha256"], + knowledge_manifest_sha256=inventory["knowledge_manifest_sha256"], + legal_approval_status="approved", + legal_reviewer="李法务", + legal_approved_at=datetime.now(UTC), + legal_approval_evidence_ref="https://gitea.example/legal/provider-acceptance.pdf", + legal_approval_evidence_sha256=hashlib.sha256(b"named legal approval evidence").hexdigest(), + ) + manifest = manifest.model_copy( + update={ + "legal_approval_attestation": provider_acceptance_attestation( + "default", + manifest, + LEGAL_ATTESTATION_SECRET, + ) + } + ) + return repository.add_provider_acceptance_run(manifest).id + + +def _stored_manifest_payload( + repository: InMemoryAiPlatformRepository, + acceptance_run_id: str, +) -> ProviderAcceptanceRunCreate: + run = repository.get_provider_acceptance_run(acceptance_run_id) + return ProviderAcceptanceRunCreate.model_validate( + run.model_dump( + exclude={"id", "tenant_id", "acceptance_manifest_sha256", "created_at"} + ) + ) + + def test_local_readiness_is_non_blocking_but_does_not_claim_provider_acceptance() -> None: client, _ = _client("local") @@ -105,22 +195,24 @@ def test_staging_readiness_blocks_placeholder_provider_configuration() -> None: assert checks["provider_acceptance"]["blocking"] is False -def test_staging_can_deploy_configured_provider_before_live_acceptance_is_selected() -> None: +def test_staging_readiness_blocks_fallback_before_provider_acceptance() -> None: client, repository = _client( "staging", provider_call_mode="enabled", - provider_fallback_enabled=False, - provider_api_key="configured-but-not-called", + provider_fallback_enabled=True, + provider_api_key="configured-but-not-called", # pragma: allowlist secret ) _configure_real_provider(repository) payload = client.get("/api/v1/quality/release-readiness").json() - assert payload["status"] == "needs_review" - assert payload["release_allowed"] is True + assert payload["status"] == "blocked" + assert payload["release_allowed"] is False assert payload["provider_acceptance_verified"] is False checks = {item["code"]: item for item in payload["checks"]} - assert checks["provider_runtime"]["status"] == "passed" + assert checks["provider_runtime"]["status"] == "failed" + assert checks["provider_runtime"]["details"]["provider_fallback_enabled"] is True + assert "provider fallback must be disabled" in checks["provider_runtime"]["details"]["problems"] assert checks["provider_acceptance"]["status"] == "warning" assert checks["provider_acceptance"]["blocking"] is False @@ -130,7 +222,7 @@ def test_production_readiness_blocks_provider_fallback_and_missing_acceptance() "production", provider_call_mode="enabled", provider_fallback_enabled=True, - provider_api_key="configured-but-not-called", + provider_api_key="configured-but-not-called", # pragma: allowlist secret ) _configure_real_provider(repository) @@ -145,65 +237,268 @@ def test_production_readiness_blocks_provider_fallback_and_missing_acceptance() assert checks["provider_acceptance"]["details"]["verified"] is False -def test_production_readiness_accepts_explicit_matching_audit_evidence_without_network() -> None: +def test_production_promotion_kill_switch_is_fail_closed() -> None: + repository = InMemoryAiPlatformRepository() + repository.seed_defaults() + client = TestClient( + create_app( + AiPlatformSettings( + environment="production", + provider_acceptance_kill_switch=True, + ), + repository=repository, + ) + ) + + payload = client.get("/api/v1/quality/release-readiness").json() + + checks = {item["code"]: item for item in payload["checks"]} + assert payload["status"] == "blocked" + assert payload["release_allowed"] is False + assert payload["provider_acceptance_verified"] is False + assert checks["provider_promotion_kill_switch"]["status"] == "failed" + assert checks["provider_promotion_kill_switch"]["blocking"] is True + assert checks["provider_promotion_kill_switch"]["details"] == {"active": True} + + +def test_production_readiness_does_not_fall_back_to_a_local_acceptance_manifest() -> None: client, repository = _client( "production", provider_call_mode="enabled", provider_fallback_enabled=False, - provider_api_key="configured-but-not-called", + provider_api_key="configured-but-not-called", # pragma: allowlist secret ) provider = _configure_real_provider(repository) - repository.add_ai_run_audit(_acceptance_run(provider.id)) + acceptance_run_id = _acceptance_manifest(provider.id, repository) response = client.get( "/api/v1/quality/release-readiness", - params={"acceptance_run_id": "provider-acceptance-001"}, + params={"acceptance_run_id": acceptance_run_id}, ) assert response.status_code == 200 payload = response.json() - assert payload["status"] == "ready" - assert payload["release_allowed"] is True - assert payload["provider_acceptance_verified"] is True + assert payload["status"] == "blocked" + assert payload["release_allowed"] is False + assert payload["provider_acceptance_verified"] is False checks = {item["code"]: item for item in payload["checks"]} assert checks["provider_runtime"]["status"] == "passed" assert checks["citation_quality_policy"]["status"] == "passed" - assert checks["provider_acceptance"]["status"] == "passed" - assert checks["provider_acceptance"]["evidence_scope"] == "live_provider_audit_run" + assert checks["provider_acceptance"]["status"] == "failed" + assert checks["provider_acceptance"]["evidence_scope"] == "signed_shared_staging_acceptance_receipt" -def test_production_readiness_rejects_fallback_or_low_quality_audit_evidence() -> None: +def test_production_readiness_requires_signed_receipt_even_with_legacy_legal_secret() -> None: client, repository = _client( "production", provider_call_mode="enabled", provider_fallback_enabled=False, - provider_api_key="configured-but-not-called", + provider_api_key="configured-but-not-called", # pragma: allowlist secret + provider_legal_attestation_secret="", ) provider = _configure_real_provider(repository) - repository.add_ai_run_audit( - _acceptance_run(provider.id).model_copy( - update={ - "id": "provider-acceptance-fallback", - "provider_status": "failed", - "status": "fallback", - "quality_status": "needs_review", - "citations_count": 0, - "retrieved_sources": [], - } - ) + acceptance_run_id = _acceptance_manifest(provider.id, repository) + + payload = client.get( + "/api/v1/quality/release-readiness", + params={"acceptance_run_id": acceptance_run_id}, + ).json() + + assert payload["status"] == "blocked" + check = next(item for item in payload["checks"] if item["code"] == "provider_acceptance") + assert "signed shared-staging acceptance receipt is required" in check["details"]["problems"] + + +def test_staging_readiness_rejects_fallback_or_low_quality_audit_evidence() -> None: + client, repository = _client( + "staging", + provider_call_mode="enabled", + provider_fallback_enabled=False, + provider_api_key="configured-but-not-called", # pragma: allowlist secret + ) + provider = _configure_real_provider(repository) + acceptance_run_id = _acceptance_manifest(provider.id, repository) + bad_index = next( + index + for index, audit in enumerate(repository.ai_run_audits) + if audit.id == "provider-acceptance-audit-01" + ) + repository.ai_run_audits[bad_index] = repository.ai_run_audits[bad_index].model_copy( + update={"provider_status": "failed", "status": "fallback", "quality_status": "needs_review"} ) payload = client.get( "/api/v1/quality/release-readiness", - params={"acceptance_run_id": "provider-acceptance-fallback"}, + params={"acceptance_run_id": acceptance_run_id}, + ).json() + + assert payload["status"] == "needs_review" + assert payload["release_allowed"] is True + check = next(item for item in payload["checks"] if item["code"] == "provider_acceptance") + assert check["status"] == "warning" + assert "did not succeed without Provider error" in check["details"]["problems"] + + +def test_production_readiness_rejects_legacy_single_audit_id() -> None: + client, repository = _client( + "production", + provider_call_mode="enabled", + provider_fallback_enabled=False, + provider_api_key="configured-but-not-called", # pragma: allowlist secret + ) + provider = _configure_real_provider(repository) + repository.add_ai_run_audit(_acceptance_run(provider.id)) + + payload = client.get( + "/api/v1/quality/release-readiness", + params={"acceptance_run_id": "provider-acceptance-001"}, ).json() assert payload["status"] == "blocked" - assert payload["release_allowed"] is False - check = next(item for item in payload["checks"] if item["code"] == "provider_acceptance") - assert check["status"] == "failed" - assert "without fallback" in check["details"]["problems"] - assert "citation count is below policy" in check["details"]["problems"] + assert payload["provider_acceptance_verified"] is False + + +@pytest.mark.parametrize( + ("field_name", "problem_fragment"), + [ + ("qa_sample_ids", "legal QA sample inventory"), + ("review_sample_ids", "contract review sample inventory"), + ("knowledge_card_ids", "knowledge card inventory"), + ], +) +def test_provider_acceptance_rejects_incomplete_required_inventory( + field_name: str, + problem_fragment: str, +) -> None: + _, repository = _client("staging") + provider = _configure_real_provider(repository) + run_id = _acceptance_manifest(provider.id, repository) + payload = _stored_manifest_payload(repository, run_id) + incomplete = payload.model_copy(update={field_name: getattr(payload, field_name)[:-1]}) + + with pytest.raises(ValueError, match=problem_fragment): + repository.add_provider_acceptance_run(incomplete) + + +def test_provider_acceptance_rejects_wrong_hash_placeholder_reviewer_and_sample_binding() -> None: + _, repository = _client("staging") + provider = _configure_real_provider(repository) + run_id = _acceptance_manifest(provider.id, repository) + payload = _stored_manifest_payload(repository, run_id) + + with pytest.raises(ValueError, match="suite manifest hash"): + repository.add_provider_acceptance_run( + payload.model_copy(update={"suite_manifest_sha256": "0" * 64}) + ) + with pytest.raises(ValueError, match="named approver"): + repository.add_provider_acceptance_run( + payload.model_copy(update={"legal_reviewer": "pending"}) + ) + swapped = list(payload.audit_bindings) + swapped[0] = swapped[0].model_copy(update={"audit_run_id": swapped[1].audit_run_id}) + swapped[1] = swapped[1].model_copy(update={"audit_run_id": payload.audit_bindings[0].audit_run_id}) + with pytest.raises(ValueError, match="exact bundled sample input"): + repository.add_provider_acceptance_run(payload.model_copy(update={"audit_bindings": swapped})) + with pytest.raises(ValueError, match="attestation does not match"): + repository.add_provider_acceptance_run( + payload.model_copy(update={"legal_approval_attestation": "b" * 64}), + legal_attestation_secret=LEGAL_ATTESTATION_SECRET, + ) + + +def test_provider_acceptance_missing_structured_legal_evidence_fails_schema_validation() -> None: + inventory = expected_acceptance_inventory() + with pytest.raises(ValidationError): + ProviderAcceptanceRunCreate.model_validate( + { + "source_environment": "shared_staging", + "acceptance_policy_version": ACCEPTANCE_POLICY_VERSION, + "provider_config_id": "provider-1", + "provider": "openai-compatible", + "model": "legal-model-v1", + "qa_sample_ids": inventory["qa_sample_ids"], + "review_sample_ids": inventory["review_sample_ids"], + "knowledge_card_ids": inventory["knowledge_card_ids"], + "audit_bindings": [], + "suite_manifest_sha256": inventory["suite_manifest_sha256"], + "knowledge_manifest_sha256": inventory["knowledge_manifest_sha256"], + } + ) + + +def test_provider_acceptance_manifest_is_tenant_isolated() -> None: + client, repository = _client( + "production", + provider_call_mode="enabled", + provider_fallback_enabled=False, + provider_api_key="configured-but-not-called", # pragma: allowlist secret + ) + provider = _configure_real_provider(repository) + run_id = _acceptance_manifest(provider.id, repository) + + payload = client.get( + "/api/v1/quality/release-readiness", + params={"acceptance_run_id": run_id}, + headers={"X-Tenant-Id": "different-tenant"}, + ).json() + + assert payload["status"] == "blocked" + assert payload["provider_acceptance_verified"] is False + + +def test_provider_acceptance_manifest_persists_idempotently_in_sqlalchemy(tmp_path: Path) -> None: + database_url = f"sqlite+pysqlite:///{(tmp_path / 'acceptance.sqlite3').as_posix()}" + repository = SqlAlchemyAiPlatformRepository(database_url) + repository.seed_defaults() + provider = _configure_real_provider(repository) # type: ignore[arg-type] + + first_id = _acceptance_manifest(provider.id, repository) # type: ignore[arg-type] + payload = ProviderAcceptanceRunCreate.model_validate( + repository.get_provider_acceptance_run(first_id).model_dump( + exclude={"id", "tenant_id", "acceptance_manifest_sha256", "created_at"} + ) + ) + assert repository.add_provider_acceptance_run(payload).id == first_id + + service_key = "s" * 48 + app = create_app( + AiPlatformSettings( + environment="staging", + repository_backend="sqlalchemy", + database_url=database_url, + require_service_auth=True, + service_shared_secret=service_key, + provider_legal_attestation_secret=LEGAL_ATTESTATION_SECRET, + ), + repository=repository, + ) + client = TestClient(app) + registration = payload.model_dump( + mode="json", + exclude={"source_environment", "acceptance_policy_version"}, + ) + service_only = client.post( + "/api/v1/quality/provider-acceptance-runs", + json=registration, + headers={"X-AI-Platform-Service-Key": service_key}, + ) + assert service_only.status_code == 422 + response = client.post( + "/api/v1/quality/provider-acceptance-runs", + json=registration, + headers={ + "X-AI-Platform-Service-Key": service_key, + "X-AI-Platform-Legal-Attestation": payload.legal_approval_attestation, + }, + ) + # The protected API no longer accepts the legacy HMAC as a legal identity. + # Historical repository fixtures remain readable and idempotent above. + assert response.status_code == 422 + + reopened = SqlAlchemyAiPlatformRepository(database_url, auto_create=False) + persisted = reopened.get_provider_acceptance_run(first_id) + assert persisted.acceptance_manifest_sha256 + assert len(persisted.audit_bindings) == 18 def test_release_readiness_gate_returns_environment_specific_ci_exit_codes() -> None: diff --git a/yuqei-ai-platform/services/worker/pyproject.toml b/yuqei-ai-platform/services/worker/pyproject.toml index 42ed7c7e..d80c980a 100644 --- a/yuqei-ai-platform/services/worker/pyproject.toml +++ b/yuqei-ai-platform/services/worker/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "httpx>=0.28", "pydantic>=2.10", "pydantic-settings>=2.7", - "pypdf>=5.0", + "pypdf>=6.15.0", "python-docx>=1.1", ] diff --git a/yuqei-contract-platform/apps/contract-web/AGENTS.md b/yuqei-contract-platform/apps/contract-web/AGENTS.md new file mode 100644 index 00000000..643577df --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/yuqei-contract-platform/apps/contract-web/CLAUDE.md b/yuqei-contract-platform/apps/contract-web/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/yuqei-contract-platform/apps/contract-web/package.json b/yuqei-contract-platform/apps/contract-web/package.json index f1e5eb2f..4ba65a0c 100644 --- a/yuqei-contract-platform/apps/contract-web/package.json +++ b/yuqei-contract-platform/apps/contract-web/package.json @@ -14,7 +14,7 @@ "dependencies": { "@yuqei/design-system": "0.1.0", "lucide-react": "1.21.0", - "next": "16.2.9", + "next": "16.3.0", "react": "19.2.7", "react-dom": "19.2.7" }, diff --git a/yuqei-contract-platform/apps/contract-web/src/app/globals.css b/yuqei-contract-platform/apps/contract-web/src/app/globals.css index 4f864021..8c00ed8d 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/globals.css +++ b/yuqei-contract-platform/apps/contract-web/src/app/globals.css @@ -6351,3 +6351,128 @@ body { min-width: 36px; } } + +.mobile-nav-toggle, +.mobile-nav-close, +.mobile-sidebar-actions, +.sidebar-scrim { + display: none; +} + +@media (max-width: 980px) { + .app-shell { + grid-template-columns: minmax(0, 1fr); + } + + .app-sidebar { + position: fixed; + z-index: 60; + inset: 0 auto 0 0; + width: min(86vw, 320px); + height: 100dvh; + max-height: 100dvh; + transform: translateX(-105%); + visibility: hidden; + transition: + transform 180ms ease, + visibility 0s linear 180ms; + } + + .app-sidebar-open { + transform: translateX(0); + visibility: visible; + transition-delay: 0s; + } + + .sidebar-scrim-visible { + position: fixed; + z-index: 50; + inset: 0; + display: block; + width: 100%; + height: 100%; + padding: 0; + border: 0; + border-radius: 0; + background: rgba(13, 20, 18, 0.58); + cursor: default; + } + + .mobile-nav-toggle, + .mobile-nav-close { + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + border: 1px solid rgba(53, 67, 62, 0.18); + border-radius: 8px; + background: rgba(255, 253, 248, 0.9); + color: var(--ink); + cursor: pointer; + } + + .mobile-nav-toggle { + display: inline-flex; + flex: 0 0 auto; + } + + .mobile-nav-close { + position: absolute; + z-index: 1; + top: 14px; + right: 14px; + display: inline-flex; + color: #fffdf8; + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.14); + } + + .app-sidebar .brand-block { + padding-right: 52px; + } + + .mobile-sidebar-actions { + display: grid; + gap: 8px; + margin-top: 4px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + } + + .mobile-sidebar-actions .workspace-switcher, + .mobile-sidebar-actions .workspace-switcher-stack, + .mobile-sidebar-actions .button { + width: 100%; + } +} + +@media (max-width: 640px) { + .app-sidebar { + gap: 10px; + height: 100dvh; + max-height: 100dvh; + } + + .app-sidebar .nav-group { + display: grid; + grid-template-columns: 1fr; + gap: 6px; + margin: 0; + padding: 0; + overflow-x: visible; + } + + .app-sidebar .nav-link { + justify-content: flex-start; + width: 100%; + min-width: 0; + padding: 7px 11px; + } +} + +@media (prefers-reduced-motion: reduce) { + .app-sidebar { + transition: none; + } +} diff --git a/yuqei-contract-platform/apps/contract-web/src/components/app-shell.tsx b/yuqei-contract-platform/apps/contract-web/src/components/app-shell.tsx index 619ee5bb..122355d3 100644 --- a/yuqei-contract-platform/apps/contract-web/src/components/app-shell.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/components/app-shell.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useContext, useEffect, type ReactNode } from "react"; +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { @@ -14,12 +14,14 @@ import { LayoutDashboard, LibraryBig, LogOut, + Menu, MessageSquareText, Scale, Search, Settings, ShieldCheck, - UsersRound + UsersRound, + X } from "lucide-react"; import { canAccessAdminPath, getRoleLabel, normalizeUserRole } from "@/lib/access-control"; import type { ContractWebSession } from "@/lib/contract-session"; @@ -75,6 +77,7 @@ const policyLinks = [ export function AppShell({ children, session }: { children: ReactNode; session: ContractWebSession | null }) { const pathname = usePathname(); const router = useRouter(); + const [isMobileNavOpen, setIsMobileNavOpen] = useState(false); const currentRole = normalizeUserRole(session?.role); const visibleAssetNav = assetNav.filter( (item) => !item.restricted @@ -104,6 +107,28 @@ export function AppShell({ children, session }: { children: ReactNode; session: return () => window.clearTimeout(prefetchTimer); }, [pathname, router, session]); + useEffect(() => { + setIsMobileNavOpen(false); + }, [pathname]); + + useEffect(() => { + if (!isMobileNavOpen) { + return; + } + const previousOverflow = document.body.style.overflow; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsMobileNavOpen(false); + } + }; + document.body.style.overflow = "hidden"; + window.addEventListener("keydown", closeOnEscape); + return () => { + document.body.style.overflow = previousOverflow; + window.removeEventListener("keydown", closeOnEscape); + }; + }, [isMobileNavOpen]); + async function logout() { await fetch("/api/auth/logout", { method: "POST" }).catch(() => null); router.push("/login"); @@ -137,7 +162,26 @@ export function AppShell({ children, session }: { children: ReactNode; session: return (
-