feat: close production trust and operations gates
Build and Test Quality Gate / Contract Web typecheck and build (pull_request) Successful in 7m28s
Build and Test Quality Gate / Python tests and Contract API migration (pull_request) Failing after 26m12s
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Successful in 6m33s
Build and Test Quality Gate / Contract Web typecheck and build (pull_request) Successful in 7m28s
Build and Test Quality Gate / Python tests and Contract API migration (pull_request) Failing after 26m12s
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Successful in 6m33s
This commit is contained in:
@@ -33,6 +33,10 @@ on:
|
||||
description: "Maximum revocation or kill-switch propagation SLA"
|
||||
required: false
|
||||
default: "300"
|
||||
max_observation_age_seconds:
|
||||
description: "Maximum accepted age of each no-cache readiness response"
|
||||
required: false
|
||||
default: "120"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -65,6 +69,7 @@ jobs:
|
||||
EXPECTED_KEY_ID: ${{ inputs.expected_key_id }}
|
||||
EXPECTED_IMAGE_DIGEST: ${{ inputs.expected_image_digest }}
|
||||
PROPAGATION_DEADLINE_SECONDS: ${{ inputs.propagation_deadline_seconds }}
|
||||
MAX_OBSERVATION_AGE_SECONDS: ${{ inputs.max_observation_age_seconds }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
@@ -76,6 +81,7 @@ jobs:
|
||||
--node "$NODE_A"
|
||||
--node "$NODE_B"
|
||||
--service-key-file .promotion-control-private/service-key
|
||||
--max-observation-age-seconds "$MAX_OBSERVATION_AGE_SECONDS"
|
||||
)
|
||||
if [ "$MODE" = "canary" ]; then
|
||||
args+=(
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# V2 production rollback and capacity audit
|
||||
|
||||
Date: 2026-08-12
|
||||
|
||||
## Scope and safety boundary
|
||||
|
||||
This audit began with repository tests and read-only SSH checks, then used an
|
||||
authorized maintenance window for backup/restore verification and a complete
|
||||
application rollback/forward rehearsal. The public edge remained stopped.
|
||||
PostgreSQL, Redis and MinIO were not rolled back, no database downgrade was run,
|
||||
and DNS/public traffic were not changed.
|
||||
|
||||
## Result
|
||||
|
||||
The primary-host capacity policy is currently within its configured limits,
|
||||
but it has little percentage headroom: the latest production health artifact
|
||||
reported `/` at 84.45% used with 28.90 GiB free, immediately below the 85%
|
||||
warning ceiling. `/home` was 16.94% used with 192.14 GiB free, and the off-host
|
||||
backup mount was 14.42% used with 68.24 GiB free. Both required paths were
|
||||
observed as separate mounts by the production health probe.
|
||||
|
||||
Capacity code and repository-level alert behavior pass their automated tests.
|
||||
The production health unit wires the protected
|
||||
`PRODUCTION_ALERT_WEBHOOK_URL_FILE` into the probe, and production preflight
|
||||
fails closed unless that file contains an owner-restricted HTTPS URL. A receiver
|
||||
on the independent host `47.94.178.212` accepted the controlled warning and
|
||||
returned a receiver-side event ID, receipt time and body hash. The normal 85%
|
||||
threshold was restored and reported `/` at 84.45%, status `pass`.
|
||||
|
||||
The production timer remains enabled while the edge is intentionally stopped,
|
||||
so the edge check is expected to fail closed. To avoid sending the identical
|
||||
known failure every minute, the probe now retains an atomic, non-secret alert
|
||||
state: the first failure, any check-set change and an hourly reminder are sent;
|
||||
failed delivery retries on the next run; a healthy run resets the state.
|
||||
|
||||
A complete application version rollback and forward restoration was rehearsed
|
||||
from current release `d6a6752258a555f3a40894cdcf88917659538492` to approved
|
||||
previous release `c24f7a4f1ee041d92feca96bf147e423e58da17c`, then back to the
|
||||
current release. All four application services were stopped/recreated in both
|
||||
directions. The Contract API and worker digests were identical between those
|
||||
releases; the AI API image changed. This limitation is recorded rather than
|
||||
misrepresenting the exercise as four distinct image rollbacks. Health, schemas,
|
||||
worker readiness, contract/template reads and audit/outbox continuity passed in
|
||||
both directions. The alert and complete version rollback gates are **closed**.
|
||||
|
||||
## Read-only field evidence
|
||||
|
||||
Primary host: `192.168.3.90` (host-reported name `10.20.0.11`).
|
||||
|
||||
Observed production services:
|
||||
|
||||
- Contract API, Contract review worker, AI Platform API, AI worker, PostgreSQL,
|
||||
Redis and MinIO were running and healthy.
|
||||
- Contract API was pinned to
|
||||
`git.yuqei.com/yuqei/ai-contract-platform-contract-api@sha256:a6969d90680f866232f1ad2c09607fd63f226476028b6c0f34c3175e7407549f`.
|
||||
- AI Platform API was pinned to
|
||||
`git.yuqei.com/yuqei/ai-contract-platform-ai-api@sha256:05faf2aa67d258708387daa7007442928b8d786814f1c5e8a619db4b1ae9d8b3`.
|
||||
- The latest normal-policy production health artifact had status `fail` only
|
||||
because `edge_health` returned HTTP 503. Contract/AI health, schema checks,
|
||||
Provider runtime, worker health, backup freshness and all three capacity
|
||||
checks passed.
|
||||
- A controlled capacity warning was received as event
|
||||
`yuqei-alert-45b05755b2fc4c498dd4eb8cd3c426ed`; its posted body SHA-256 was
|
||||
`933d5741e600efec3190e79892a93165bb94a57f00976184fde31ec43148eeff`.
|
||||
- The receiver-owned mode `0600` receipt SHA-256 was
|
||||
`8816696a8829215ce7e7821a0965d35d48a27651c89b1a267846d84bd664f909`.
|
||||
- Backup `backup-20260812T025650Z` completed in quiesced mode with four
|
||||
artifacts. Disposable restoration of all three PostgreSQL databases passed;
|
||||
summary SHA-256 was
|
||||
`70e6c223a6870dd0d8fe28365bda7f883fc6827a77ddcc0da397dd77cbee4370`.
|
||||
- `/etc/yuqei/production.env` existed with mode `0600`.
|
||||
- `yuqei-production-health.timer` was enabled and active at a one-minute cadence.
|
||||
- `/etc/yuqei/monitoring/alert-webhook-url` is configured without exposing its
|
||||
secret URL in repository evidence. It is mode `0600`, owned by the unprivileged
|
||||
probe account. The receiver is loopback-bound behind HTTPS.
|
||||
- After deploying deduplication, the first expected `edge_health` failure was
|
||||
delivered as `yuqei-alert-9014cb51c85241f5b63836bf8cb1a510`; an immediate
|
||||
second identical run recorded `duplicate_within_resend_window`, did not call
|
||||
the receiver, left the timer active and kept the edge replica count at `0`.
|
||||
- Current production read-only preflight passed with `failed=0`. Its retained
|
||||
summary SHA-256 is
|
||||
`3b1e2e3b25272adf4c1443fb98cbe7284b520d82808836f879de58c4e4240556`.
|
||||
|
||||
## Complete rollback and forward evidence
|
||||
|
||||
- Remote evidence directory:
|
||||
`/opt/apps/ai-contract-platform-v2-production/project-docs/qa-artifacts/v2-production-full-rollback/20260812T030852Z`.
|
||||
- Manifest SHA-256:
|
||||
`2ac616acc69868a8274ab0047d42dcdcd2b8ff29660ac68486fcbdc007d1d4a4`.
|
||||
- Evidence file count: `40`; manifest status: `passed`.
|
||||
- Before, rollback and forward phases all passed Contract health/schema, AI
|
||||
health/schema, worker health, contract-list read, template-list read and
|
||||
audit/outbox count continuity.
|
||||
- The edge replica count remained `0`. Provider readiness remained blocked by
|
||||
the intentional kill switch and, on the previous release, missing receipt
|
||||
binding. This is expected fail-closed behavior, not a failed recovery check.
|
||||
- An exit trap restored the current environment on any failure. Final inspection
|
||||
confirmed the current release and all four application services healthy.
|
||||
|
||||
## Independent public-policy trust evidence
|
||||
|
||||
- Legal approval ID: `policy-approval-0887bbd5149bf7a1c41cb35e`.
|
||||
- Trust request SHA-256:
|
||||
`d2d76ee0789b235af67f1df3ae3a86237467ca72bbf76ad6a613e35a0f996eee`.
|
||||
- Independent operations reviewer: Chen Yong, stable approver ID
|
||||
`chenyong@yuqei.com`, key ID `policy-trust-ops-2026q3`.
|
||||
- Independent public-key file SHA-256:
|
||||
`0e2b147e5da9d1084da368944c720821a053b866282f1c5f9385dbad529e4009`.
|
||||
- Exact trust payload SHA-256:
|
||||
`1b783cb6a9f1976d50f7d8b4c77e286425d7c8bec7c597057a172b6eaa113442`.
|
||||
- Signed trust approval ID:
|
||||
`policy-key-trust-approval-09d9860e94d6df9fe74a0d1e`; approval
|
||||
SHA-256:
|
||||
`5093462cefff6a75e5f9a320f8bd4e033d9327e0234f84e9bbc258c36148fbbc`.
|
||||
- Trusted policy keyring SHA-256:
|
||||
`41f9517dcd375afbeea4aaa4dce5d8bbe07db8f71e2706d47e7aec5ccd8bd388`.
|
||||
- Strict verification at `2026-08-12T12:40:02.047339+08:00` passed for all
|
||||
four exact policy files without candidate-keyring override.
|
||||
- The signed decision binds the out-of-band identity/authority and legal-key
|
||||
fingerprint attestations. `policy_activation_authorized=false`; the trusted
|
||||
keyring remains local and has not been installed in production. No policy was
|
||||
published or activated by this approval.
|
||||
|
||||
Host filesystem observations were also checked directly with `df -P`: root was
|
||||
85% at `df` display precision, while `/home` was 17%. The application probe's
|
||||
non-reserved-capacity calculation is the authoritative threshold comparison and
|
||||
reported root at 84.45%.
|
||||
|
||||
## Repository verification
|
||||
|
||||
Executed on 2026-08-12:
|
||||
|
||||
| Command | Result |
|
||||
| --- | --- |
|
||||
| `python -m pytest yuqei-infra/tests/test_observability_capacity.py yuqei-infra/tests/test_alert_receiver.py -q` | `26 passed` |
|
||||
| `python -m pytest yuqei-infra/tests -q` | `148 passed` |
|
||||
| `python -m py_compile` on all changed Python entrypoints | passed |
|
||||
| `python -m bandit -q` on all changed Python entrypoints | passed |
|
||||
| `git diff --check` | passed; only existing line-ending warnings |
|
||||
|
||||
The capacity tests cover warning/critical percent and free-space thresholds,
|
||||
reserved filesystem blocks, separate-mount fail-closed behavior, webhook success,
|
||||
webhook timeout, receiver authentication and immutable receipts, healthy
|
||||
no-alert behavior, changed-state and one-hour resend deduplication, production
|
||||
preflight thresholds, and the installed systemd arguments.
|
||||
|
||||
## Capacity and alert operating policy
|
||||
|
||||
1. Keep the HTTPS URL in its owner-restricted file; never put its secret path or
|
||||
token in the repository, environment file or audit report.
|
||||
2. Retain the receiver-side receipt, local summary and event ID for each
|
||||
acceptance test.
|
||||
3. Keep one-hour unchanged-alert deduplication enabled. Do not suppress the first
|
||||
failure, a changed check set, delivery retries or hourly reminders.
|
||||
4. Treat root at or above 85%, free below 20 GiB, or more than five percentage
|
||||
points growth in 24 hours as a release stop. Do not delete business volumes,
|
||||
Registry data or backups to satisfy the gate. Move rebuildable caches/logs only
|
||||
after an explicit inventory and retention approval, or extend the root LV.
|
||||
|
||||
## Complete version rollback acceptance policy
|
||||
|
||||
The completed exercise followed these controls. Re-run it after a material
|
||||
release topology, image or migration change; do not infer future downgrade
|
||||
compatibility from this one result.
|
||||
|
||||
1. Record the current release ID, all image digests, receipt/key fingerprints,
|
||||
Alembic heads, container health and golden-smoke results.
|
||||
2. Take and verify a new off-host production backup. Restore all three dumps into
|
||||
disposable `yuqei_restore_check_*` databases before continuing.
|
||||
3. Prove migration compatibility. If the previous application cannot read the
|
||||
current schema, stop: use forward-fix recovery unless a separately reviewed,
|
||||
data-preserving downgrade exists. Never infer downgrade safety from an Alembic
|
||||
command succeeding in an empty database.
|
||||
4. With edge and public traffic still disabled, switch application services only
|
||||
to the approved previous digests. Do not roll back PostgreSQL/Redis/MinIO data
|
||||
or delete audit/outbox records.
|
||||
5. Verify health, schema checks, immutable release fingerprints, login/workspace,
|
||||
contract list/detail, template drafting, review read path and audit continuity.
|
||||
6. Return to the current immutable digests, re-run migrations in the forward
|
||||
direction, and repeat the same checks.
|
||||
7. Capture start/end times, RTO, every command exit code, before/rollback/forward
|
||||
fingerprints, database heads, release-gate summaries and operator approval.
|
||||
8. Abort immediately on migration drift, partial health, missing audit/outbox data,
|
||||
receipt mismatch, unexpected writes, or any need to use an unpinned image.
|
||||
|
||||
Acceptance requires both rollback and forward-restoration checks to pass, the
|
||||
database and immutable audit history to remain intact, and an operator-owned
|
||||
evidence package. The 2026-08-12 rehearsal satisfies this gate for the recorded
|
||||
release pair.
|
||||
|
||||
## Gate status
|
||||
|
||||
| Gate | Status | Reason |
|
||||
| --- | --- | --- |
|
||||
| Capacity thresholds and live primary-host readings | pass, narrow headroom | root 84.45% used; warning begins at 85% |
|
||||
| Capacity timer and local alert contract | pass | timer active; automated warning/failure delivery semantics pass |
|
||||
| Real external alert delivery | pass | HTTPS receiver event and immutable receipt hashes captured |
|
||||
| Backup integrity and disposable DB restore | pass from existing field evidence | three dumps and restore checks completed |
|
||||
| Complete production version rollback and forward restore | pass | 40-file evidence manifest passed for previous/current release pair |
|
||||
| Independent public-policy trust approval | pass, policy not activated | Chen Yong signed the exact trust payload as operations reviewer; strict verification issued a non-activating trusted keyring |
|
||||
| Public traffic authorization | not in scope | edge/DNS/traffic remain separately controlled |
|
||||
@@ -31,13 +31,20 @@
|
||||
| `service_agreement` | `service-agreement.md` | `214f5744039dc8ebf554f6ead06e5a9fb3ec08ea973719bb59fc8e3f8b1d5f48` |
|
||||
| `ai_use_notice` | `ai-use-notice.md` | `f261df70a29109e90a537f5812d871591e4787716e008d7966ed9b5e79ebcdfd` |
|
||||
|
||||
当前未签名材料位于 `.runtime/production-closure/policies/2026-08-12/`:
|
||||
当前签批材料位于 `.runtime/production-closure/policies/2026-08-12/`:
|
||||
|
||||
- review package SHA-256:`0b3c105feaf5025e20b6799701ec19b8580837ff2ad71297baf96af56a1d8e62`
|
||||
- pending payload SHA-256:`7f543f8980ceedc085d451c6c8d691d5ba93fa5f11baef276f4bd004a1b4eba5`
|
||||
- final payload SHA-256:`7969de1c7c8384657b42a3fd16e70bb83a0430d0100b33088c4a094c48b17312`
|
||||
- signed approval SHA-256:`84f45dd4ec188eb29509f8ce75787862099558b24ad1bd42dc27f976c24a5719`
|
||||
- approval ID:`policy-approval-0887bbd5149bf7a1c41cb35e`
|
||||
- candidate keyring SHA-256:`1f85a758252d571aec4233c864714ad2fb637940ebb2f0d2e37d1818b09f9df4`
|
||||
- independent trust payload SHA-256:`1b783cb6a9f1976d50f7d8b4c77e286425d7c8bec7c597057a172b6eaa113442`
|
||||
- independent trust approval ID:`policy-key-trust-approval-09d9860e94d6df9fe74a0d1e`
|
||||
- independent trust approval SHA-256:`5093462cefff6a75e5f9a320f8bd4e033d9327e0234f84e9bbc258c36148fbbc`
|
||||
- trusted keyring SHA-256:`41f9517dcd375afbeea4aaa4dce5d8bbe07db8f71e2706d47e7aec5ccd8bd388`
|
||||
|
||||
这三个文件当前状态分别是 `pending_named_legal_review` 或 `candidate_not_trusted`,不得签名、发布或安装为生产信任材料。
|
||||
review package 和 pending payload 仍是历史输入材料。陈勇已作为独立运营复核人签署精确信任 payload;工具从原始 trust request、法务批准、候选 keyring、四份政策字节和独立公钥重新验证后生成 trusted keyring,并在未启用 candidate override 的情况下完成四份政策验签。`trust_basis.policy_activation_authorized=false`,所以该结果只关闭公钥信任审批门禁,不表示政策已经发布或生效。trusted keyring 当前仅保存在本机受控 `.runtime` 目录,尚未安装到生产。
|
||||
|
||||
## 审批人审阅清单
|
||||
|
||||
@@ -64,7 +71,7 @@ python yuqei-infra/scripts/prepare-policy-legal-approval.py finalize-payload `
|
||||
--output .runtime/production-closure/policies/2026-08-12/policy-approval-final-payload.json
|
||||
```
|
||||
|
||||
记录命令输出的最终 payload SHA-256。该 hash 仍未签名,审批人必须再次核对批准时间、失效时间和 exact hash。
|
||||
记录命令输出的最终 payload SHA-256。签名前,审批人必须再次核对批准时间、失效时间和 exact hash;本次最终 payload hash 为 `7969de1c7c8384657b42a3fd16e70bb83a0430d0100b33088c4a094c48b17312`。
|
||||
|
||||
## 离线签名
|
||||
|
||||
@@ -81,9 +88,81 @@ python yuqei-infra/scripts/prepare-policy-legal-approval.py sign `
|
||||
|
||||
签名命令会拒绝错误确认字符串、非最终 payload、错误密码、非 Ed25519 私钥或与 payload 公钥绑定不一致的私钥。
|
||||
|
||||
本次签名已完成并通过验签,产物为 `policy-legal-approval.json`,`approval_id` 为 `policy-approval-0887bbd5149bf7a1c41cb35e`。该结果仅证明精确政策 payload 已由具名审批人签署,不代表政策已发布或已生效。
|
||||
|
||||
## 独立信任申请
|
||||
|
||||
法务签名只证明审批人签署了精确政策 payload,不会自动使候选公钥成为生产信任。签名完成后,先生成不可变的信任申请:
|
||||
|
||||
```powershell
|
||||
python yuqei-infra/scripts/prepare-policy-legal-approval.py prepare-trust-request `
|
||||
--approval-file .runtime/production-closure/policies/2026-08-12/policy-legal-approval.json `
|
||||
--candidate-keyring-file .runtime/production-closure/policies/2026-08-12/policy-legal-keyring.candidate.json `
|
||||
--policy-dir project-docs/legal-policy-candidates/2026-08-12 `
|
||||
--requested-at <ISO-8601-with-timezone> `
|
||||
--output .runtime/production-closure/policies/2026-08-12/policy-key-trust-request.json
|
||||
```
|
||||
|
||||
该命令会在审批有效期内重新验签,并绑定签名文件、候选 keyring 和四份政策原始字节。输出状态固定为 `pending_independent_trust_approval`,`activation_authorized` 固定为 `false`;它不会生成 trusted keyring、发布政策、切换 `effective`、启动 edge 或修改公网流量。
|
||||
|
||||
安全或运维负责人必须通过签名流程之外的渠道核对审批人身份、授权范围和公钥指纹,具名批准信任申请并保留证据。不得仅把候选 JSON 的 `status` 字段手工改为 `trusted` 作为信任证据。
|
||||
|
||||
独立审批人必须与法务审批人不同,并提供:真实姓名、稳定工作邮箱或 approver ID、职务、组织全称、独立 `key_id`、本人控制的 Ed25519 公钥 PEM、公钥有效期和本次批准有效期。approver ID 及公钥都不得与法务审批人相同;本次批准最长 7 天,且不得晚于原法务批准失效时间。
|
||||
|
||||
先生成精确待签 payload:
|
||||
|
||||
```powershell
|
||||
python yuqei-infra/scripts/prepare-policy-legal-approval.py prepare-trust-approval `
|
||||
--trust-request-file .runtime/production-closure/policies/2026-08-12/policy-key-trust-request.json `
|
||||
--approval-file .runtime/production-closure/policies/2026-08-12/policy-legal-approval.json `
|
||||
--candidate-keyring-file .runtime/production-closure/policies/2026-08-12/policy-legal-keyring.candidate.json `
|
||||
--policy-dir project-docs/legal-policy-candidates/2026-08-12 `
|
||||
--trust-approver-public-key-file <independent-trust-approver-public.pem> `
|
||||
--key-id <independent-key-id> `
|
||||
--approver-id <independent-work-id> `
|
||||
--reviewer <real-name> `
|
||||
--role <security-or-operations-role> `
|
||||
--organization <organization> `
|
||||
--responsibility <security-or-operations> `
|
||||
--approved-at <ISO-8601-with-timezone> `
|
||||
--expires-at <ISO-8601-with-timezone> `
|
||||
--key-not-before <ISO-8601-with-timezone> `
|
||||
--key-not-after <ISO-8601-with-timezone> `
|
||||
--output .runtime/production-closure/policies/2026-08-12/policy-key-trust-approval-payload.json
|
||||
```
|
||||
|
||||
审批人核对命令输出的 `trust_approval_payload_sha256` 后,使用其本人控制且与法务密钥不同的私钥签名:
|
||||
|
||||
待签 payload 固定包含三项不可省略声明:已通过线下渠道核验法务审批人身份和权限、已通过线下渠道核验法务签名公钥指纹,以及本人承担独立安全或运营复核责任。审批人必须在签名前真实完成这些核验;工具不会替审批人作此事实判断。
|
||||
|
||||
```powershell
|
||||
python yuqei-infra/scripts/prepare-policy-legal-approval.py sign-trust-approval `
|
||||
--payload-file .runtime/production-closure/policies/2026-08-12/policy-key-trust-approval-payload.json `
|
||||
--private-key-file <independent-trust-approver-private.pem> `
|
||||
--private-key-password-file <protected-password-file> `
|
||||
--confirmation I_APPROVE_THIS_EXACT_POLICY_KEY_TRUST_PAYLOAD `
|
||||
--output .runtime/production-closure/policies/2026-08-12/policy-key-trust-approval.json
|
||||
```
|
||||
|
||||
最后从所有原始材料重新验签并生成新 keyring 文件:
|
||||
|
||||
```powershell
|
||||
python yuqei-infra/scripts/prepare-policy-legal-approval.py issue-trusted-keyring `
|
||||
--trust-approval-file .runtime/production-closure/policies/2026-08-12/policy-key-trust-approval.json `
|
||||
--trust-request-file .runtime/production-closure/policies/2026-08-12/policy-key-trust-request.json `
|
||||
--approval-file .runtime/production-closure/policies/2026-08-12/policy-legal-approval.json `
|
||||
--candidate-keyring-file .runtime/production-closure/policies/2026-08-12/policy-legal-keyring.candidate.json `
|
||||
--policy-dir project-docs/legal-policy-candidates/2026-08-12 `
|
||||
--trust-approver-public-key-file <independent-trust-approver-public.pem> `
|
||||
--checked-at <ISO-8601-with-timezone> `
|
||||
--output .runtime/production-closure/policies/2026-08-12/policy-legal-keyring.trusted.json
|
||||
```
|
||||
|
||||
`issue-trusted-keyring` 会拒绝过期审批、同一 approver ID、相同公钥、手工翻转 `status`、任一 hash/文件字节漂移和签名错误。输出中的 `trust_basis.policy_activation_authorized` 固定为 `false`;trusted keyring 只建立验签公钥信任,仍不代表政策已经发布或生效。
|
||||
|
||||
## 验签与生产边界
|
||||
|
||||
候选 keyring 不能直接作为生产信任。安全或运维负责人应核对公钥来源和审批人身份,将状态从 `candidate_not_trusted` 独立提升为 `trusted`,并保留安装证据。随后运行:
|
||||
候选 keyring 不能直接作为生产信任。只有上述 `issue-trusted-keyring` 生成且自带可重验独立签名证据的文件才能用于后续验签。随后运行:
|
||||
|
||||
```powershell
|
||||
python yuqei-infra/scripts/prepare-policy-legal-approval.py verify `
|
||||
|
||||
@@ -52,6 +52,7 @@ BACKUP_DISK_WARNING_PERCENT=85
|
||||
BACKUP_DISK_CRITICAL_PERCENT=90
|
||||
BACKUP_DISK_WARNING_FREE_GIB=20
|
||||
BACKUP_DISK_CRITICAL_FREE_GIB=10
|
||||
PRODUCTION_ALERT_WEBHOOK_URL_FILE=/etc/yuqei/monitoring/alert-webhook-url
|
||||
|
||||
# The file is mounted as /run/secrets/ai_provider_api_key. Configure the default
|
||||
# database provider with api_key_secret_ref=file:///run/secrets/ai_provider_api_key.
|
||||
|
||||
@@ -17,6 +17,10 @@ The production backend runs on `192.168.3.90`; Contract Web is deployed separate
|
||||
3. Mount an off-host backup filesystem at `BACKUP_HOST_DIR`.
|
||||
4. Place `tls.crt`, `tls.key`, and the AI provider key in protected host directories. When enabling SMTP, also create the file named by `CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST`, mode `0600`.
|
||||
5. Copy `.env.production.example` to `/etc/yuqei/production.env`, replace every placeholder, and keep mode `0600`.
|
||||
Create the file named by `PRODUCTION_ALERT_WEBHOOK_URL_FILE`, containing only
|
||||
the approved HTTPS alert receiver URL. Keep it owner-only and readable by the
|
||||
`nobody` account used by the production health unit (for example, owner
|
||||
`nobody:nobody`, mode `0600`).
|
||||
6. Run the read-only gate:
|
||||
|
||||
```bash
|
||||
@@ -71,6 +75,22 @@ api_key_secret_ref=file:///run/secrets/ai_provider_api_key
|
||||
|
||||
Do not send the key in the model-config API payload. Run the Provider acceptance gate before any production start approval.
|
||||
|
||||
The production Provider canary is read-only. Run the protected
|
||||
`provider-promotion-canary.yml` workflow against at least two distinct HTTPS nodes after
|
||||
the signed receipt, public keyring, immutable release and image digest are pinned. The
|
||||
gate sends `Cache-Control: no-cache, no-store` and fails closed unless every response:
|
||||
|
||||
- declares `environment=production` and `verification_mode=offline_read_only`;
|
||||
- is fresh within `max_observation_age_seconds` (120 seconds by default);
|
||||
- reports consistent top-level, check-detail, and promotion-diagnostic state; and
|
||||
- matches the expected release, receipt, signing key and image digest.
|
||||
|
||||
Revocation and kill-switch modes apply the same provenance and freshness checks after
|
||||
all nodes report the blocking control. The workflow performs GET requests only; it does
|
||||
not change a keyring, toggle the kill switch, import a receipt, or invoke any identity
|
||||
write/execute API. Make control changes through the independently approved operations
|
||||
procedure, then use this workflow only to observe their propagation.
|
||||
|
||||
## Alibaba Cloud frontend variables
|
||||
|
||||
The frontend deployment must use server-only values:
|
||||
|
||||
@@ -28,6 +28,7 @@ PLACEHOLDER_MARKERS = ("change_me", "local-only", "example.com", "your-api-key",
|
||||
LOOPBACK_VALUES = {"127.0.0.1", "::1", "localhost"}
|
||||
PROVIDER_ACCEPTANCE_RECEIPT_MAX_BYTES = 1024 * 1024
|
||||
PROVIDER_ACCEPTANCE_KEYRING_MAX_BYTES = 256 * 1024
|
||||
ALERT_WEBHOOK_MAX_BYTES = 16 * 1024
|
||||
SMTP_CREDENTIAL_CONTAINER_PATH = "/run/secrets/contract_smtp_password"
|
||||
LAUNCH_SCOPE_MANIFEST_PATH = (
|
||||
REPO_ROOT / "project-docs" / "V2-production-launch-scope-2026-08-12.json"
|
||||
@@ -145,6 +146,7 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False)
|
||||
"AI_PLATFORM_PROVIDER_ACCEPTANCE_RECEIPT_FILE_HOST",
|
||||
"AI_PLATFORM_PROVIDER_ACCEPTANCE_TRUSTED_KEYS_FILE_HOST",
|
||||
"AI_PLATFORM_PROVIDER_ACCEPTANCE_EXPECTED_RECEIPT_SHA256",
|
||||
"PRODUCTION_ALERT_WEBHOOK_URL_FILE",
|
||||
)
|
||||
missing = [key for key in required if not values.get(key, "").strip()]
|
||||
checks.append(_result("required_values", not missing, "All required values are present." if not missing else f"Missing required keys: {', '.join(missing)}."))
|
||||
@@ -375,6 +377,38 @@ def validate_files(values: dict[str, str], *, template_mode: bool = False) -> li
|
||||
require_mount = values.get("BACKUP_REQUIRE_SEPARATE_MOUNT", "true").lower() == "true"
|
||||
backup_mount_ok = backup_root.is_dir() and (not require_mount or os.path.ismount(backup_root))
|
||||
checks.append(_result("backup_mount", backup_mount_ok, "Backup destination is an available separate mount." if backup_mount_ok else "Backup destination is missing or is not a separate mount."))
|
||||
|
||||
webhook_path = Path(values.get("PRODUCTION_ALERT_WEBHOOK_URL_FILE", ""))
|
||||
webhook_ok = _is_bounded_absolute_file(webhook_path, ALERT_WEBHOOK_MAX_BYTES)
|
||||
webhook_url = ""
|
||||
if webhook_ok:
|
||||
try:
|
||||
webhook_url = webhook_path.read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeError):
|
||||
webhook_ok = False
|
||||
parsed_webhook = urlparse(webhook_url)
|
||||
webhook_ok = webhook_ok and parsed_webhook.scheme == "https" and bool(parsed_webhook.hostname)
|
||||
checks.append(
|
||||
_result(
|
||||
"alert_webhook_file",
|
||||
webhook_ok,
|
||||
"Production alert webhook file is an owner-only HTTPS URL."
|
||||
if webhook_ok
|
||||
else "Production alert webhook file must be an absolute, owner-only file containing an HTTPS URL.",
|
||||
)
|
||||
)
|
||||
if webhook_ok and os.name != "nt":
|
||||
mode = stat.S_IMODE(webhook_path.stat().st_mode)
|
||||
secure_mode = mode & 0o077 == 0
|
||||
checks.append(
|
||||
_result(
|
||||
"alert_webhook_permissions",
|
||||
secure_mode,
|
||||
"Production alert webhook file is owner-only."
|
||||
if secure_mode
|
||||
else f"Alert webhook file mode {oct(mode)} exposes group or other permissions.",
|
||||
)
|
||||
)
|
||||
return checks
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,11 @@ python3 yuqei-infra/observability/check-production-health.py \
|
||||
--webhook-url-file /etc/yuqei/secrets/alert-webhook-url
|
||||
```
|
||||
|
||||
Install the included systemd service and timer for one-minute checks. The webhook file is optional but, when configured, must contain an HTTPS URL and mode `0600`.
|
||||
Install the included systemd service and timer for one-minute checks. Production
|
||||
preflight requires `PRODUCTION_ALERT_WEBHOOK_URL_FILE` to point to an absolute,
|
||||
owner-only file containing the approved HTTPS receiver URL. The included unit
|
||||
passes that file to every one-minute probe; do not put the URL or credentials in
|
||||
the repository or directly in the environment file.
|
||||
|
||||
The production environment template fixes warning/critical ceilings at 85%/90%
|
||||
used and free-space floors at 20/10 GiB for each monitored filesystem. A path
|
||||
@@ -45,6 +49,13 @@ systemd records the alerting failure. Every run writes
|
||||
alert-delivery result, under
|
||||
`project-docs/qa-artifacts/v2-production-health/`.
|
||||
|
||||
The installed service deduplicates an unchanged failing check set for one hour.
|
||||
The first failure, any failed/warning check-set change, and the hourly reminder
|
||||
are delivered. A failed delivery is retried on the next one-minute run, and a
|
||||
healthy run resets the state so a recurrence is delivered immediately. The
|
||||
state is written atomically beside the health artifacts and contains no webhook
|
||||
URL or credential.
|
||||
|
||||
The production unit uses the host's `/usr/bin/python3.11` and runs as the
|
||||
unprivileged `nobody` account so NFS backup manifests remain readable under the
|
||||
export's root-squash policy.
|
||||
@@ -58,4 +69,18 @@ systemd-analyze verify \
|
||||
yuqei-infra/observability/yuqei-production-health.timer
|
||||
```
|
||||
|
||||
After deployment, alert acceptance must use a temporary command-line threshold
|
||||
below current usage and an isolated artifact directory. Do not fill a filesystem
|
||||
or edit the installed timer to inject a warning. Retain both the generated
|
||||
summary (`attempted=true`, `delivered=true`) and the receiver-side event ID.
|
||||
|
||||
`receive-production-alert.py` and `yuqei-production-alert-receiver.service`
|
||||
provide a minimal self-hosted receiver when an approved third-party receiver is
|
||||
not available. Run it as the dedicated `yuqei-alert` user on a separate node,
|
||||
bind it to loopback, and expose only `POST /ops/alerts/<random-token>` through an
|
||||
existing HTTPS reverse proxy. Keep the token in owner-restricted files on both
|
||||
nodes. Each accepted alert is written as a mode `0600` JSON receipt containing a
|
||||
unique event ID, receipt time, source, request-body SHA-256, and the exact health
|
||||
payload. Do not expose the receipt directory or add a public read endpoint.
|
||||
|
||||
This probe is a minimum launch guard, not a replacement for centralized logs, long-term metrics, PostgreSQL slow-query analysis, and infrastructure dashboards.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -20,6 +21,7 @@ from urllib.request import Request, urlopen
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_ARTIFACT_ROOT = REPO_ROOT / "project-docs" / "qa-artifacts" / "v2-production-health"
|
||||
DEFAULT_ALERT_RESEND_SECONDS = 3600
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -170,7 +172,7 @@ def check_disk(
|
||||
)
|
||||
|
||||
|
||||
def send_webhook(webhook_file: Path, summary: dict[str, Any], *, timeout: float) -> None:
|
||||
def send_webhook(webhook_file: Path, summary: dict[str, Any], *, timeout: float) -> dict[str, str]:
|
||||
url = webhook_file.read_text(encoding="utf-8").strip()
|
||||
_require_http_url(url, allow_http=False)
|
||||
body = json.dumps(summary, ensure_ascii=False).encode("utf-8")
|
||||
@@ -178,6 +180,75 @@ def send_webhook(webhook_file: Path, summary: dict[str, Any], *, timeout: float)
|
||||
with urlopen(request, timeout=timeout) as response: # nosec B310
|
||||
if response.status >= 400:
|
||||
raise RuntimeError(f"Alert webhook returned HTTP {response.status}.")
|
||||
content_type = response.headers.get_content_type()
|
||||
if content_type != "application/json":
|
||||
return {}
|
||||
try:
|
||||
receipt = json.loads(response.read().decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError("Alert webhook returned invalid JSON.") from exc
|
||||
if not isinstance(receipt, dict):
|
||||
raise RuntimeError("Alert webhook receipt must be a JSON object.")
|
||||
return {
|
||||
key: value
|
||||
for key in ("event_id", "received_at", "body_sha256")
|
||||
if isinstance((value := receipt.get(key)), str) and value
|
||||
}
|
||||
|
||||
|
||||
def alert_fingerprint(summary: dict[str, Any]) -> str:
|
||||
active_state = {
|
||||
"status": summary["status"],
|
||||
"failed_checks": sorted(summary["failed_checks"]),
|
||||
"warning_checks": sorted(summary["warning_checks"]),
|
||||
}
|
||||
encoded = json.dumps(active_state, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def should_deliver_alert(
|
||||
state_file: Path | None,
|
||||
*,
|
||||
fingerprint: str,
|
||||
now: datetime,
|
||||
resend_seconds: int,
|
||||
) -> tuple[bool, str]:
|
||||
if state_file is None:
|
||||
return True, "deduplication_not_configured"
|
||||
try:
|
||||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
previous_fingerprint = state.get("fingerprint")
|
||||
delivered_at = datetime.fromisoformat(str(state["delivered_at"]).replace("Z", "+00:00"))
|
||||
if delivered_at.tzinfo is None:
|
||||
raise ValueError("delivery timestamp has no timezone")
|
||||
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
|
||||
return True, "no_valid_previous_delivery"
|
||||
if previous_fingerprint != fingerprint:
|
||||
return True, "alert_state_changed"
|
||||
elapsed = (now.astimezone(timezone.utc) - delivered_at.astimezone(timezone.utc)).total_seconds()
|
||||
if elapsed >= resend_seconds:
|
||||
return True, "periodic_resend_due"
|
||||
return False, "duplicate_within_resend_window"
|
||||
|
||||
|
||||
def write_alert_state(
|
||||
state_file: Path,
|
||||
*,
|
||||
fingerprint: str | None,
|
||||
delivered_at: datetime | None,
|
||||
receipt: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
state = {
|
||||
"schema_version": "yuqei-production-alert-state.v1",
|
||||
"fingerprint": fingerprint,
|
||||
"delivered_at": delivered_at.isoformat() if delivered_at else None,
|
||||
"receiver_event_id": (receipt or {}).get("event_id"),
|
||||
}
|
||||
temporary = state_file.with_name(f".{state_file.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(state, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.chmod(temporary, 0o600)
|
||||
temporary.replace(state_file)
|
||||
|
||||
|
||||
def read_secret_file(path: Path | None) -> str | None:
|
||||
@@ -243,12 +314,17 @@ def _parse_args() -> argparse.Namespace:
|
||||
)
|
||||
parser.add_argument("--timeout-seconds", type=float, default=10.0)
|
||||
parser.add_argument("--webhook-url-file", type=Path)
|
||||
parser.add_argument("--alert-state-file", type=Path)
|
||||
parser.add_argument("--alert-resend-seconds", type=int, default=DEFAULT_ALERT_RESEND_SECONDS)
|
||||
parser.add_argument("--artifact-dir", type=Path)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _parse_args()
|
||||
if not 60 <= args.alert_resend_seconds <= 86400:
|
||||
print("PRODUCTION_HEALTH_FAIL invalid_alert_resend_seconds")
|
||||
return 2
|
||||
capacity_policies = [
|
||||
{
|
||||
"key": "disk_root",
|
||||
@@ -344,6 +420,11 @@ def main() -> int:
|
||||
"attempted": False,
|
||||
"delivered": False,
|
||||
"error": None,
|
||||
"receiver_event_id": None,
|
||||
"receiver_received_at": None,
|
||||
"receiver_body_sha256": None,
|
||||
"suppressed": False,
|
||||
"decision": None,
|
||||
},
|
||||
"checks": [asdict(check) for check in checks],
|
||||
}
|
||||
@@ -352,15 +433,48 @@ def main() -> int:
|
||||
summary_path = artifact_dir / "production-health-summary.json"
|
||||
alert_delivery_failed = False
|
||||
if (failed or warnings) and args.webhook_url_file:
|
||||
summary["alert_delivery"]["attempted"] = True
|
||||
try:
|
||||
send_webhook(args.webhook_url_file, summary, timeout=args.timeout_seconds)
|
||||
except Exception as exc:
|
||||
alert_delivery_failed = True
|
||||
summary["alert_delivery"]["error"] = type(exc).__name__
|
||||
print(f"PRODUCTION_HEALTH_WEBHOOK_FAIL error={type(exc).__name__}", file=sys.stderr)
|
||||
fingerprint = alert_fingerprint(summary)
|
||||
deliver, decision = should_deliver_alert(
|
||||
args.alert_state_file,
|
||||
fingerprint=fingerprint,
|
||||
now=checked_at,
|
||||
resend_seconds=args.alert_resend_seconds,
|
||||
)
|
||||
summary["alert_delivery"]["decision"] = decision
|
||||
if deliver:
|
||||
summary["alert_delivery"]["attempted"] = True
|
||||
try:
|
||||
receipt = send_webhook(args.webhook_url_file, summary, timeout=args.timeout_seconds) or {}
|
||||
except Exception as exc:
|
||||
alert_delivery_failed = True
|
||||
summary["alert_delivery"]["error"] = type(exc).__name__
|
||||
print(f"PRODUCTION_HEALTH_WEBHOOK_FAIL error={type(exc).__name__}", file=sys.stderr)
|
||||
else:
|
||||
summary["alert_delivery"]["delivered"] = True
|
||||
summary["alert_delivery"]["receiver_event_id"] = receipt.get("event_id")
|
||||
summary["alert_delivery"]["receiver_received_at"] = receipt.get("received_at")
|
||||
summary["alert_delivery"]["receiver_body_sha256"] = receipt.get("body_sha256")
|
||||
if args.alert_state_file:
|
||||
try:
|
||||
write_alert_state(
|
||||
args.alert_state_file,
|
||||
fingerprint=fingerprint,
|
||||
delivered_at=checked_at,
|
||||
receipt=receipt,
|
||||
)
|
||||
except OSError:
|
||||
alert_delivery_failed = True
|
||||
summary["alert_delivery"]["error"] = "StateWriteError"
|
||||
print("PRODUCTION_HEALTH_ALERT_STATE_FAIL", file=sys.stderr)
|
||||
else:
|
||||
summary["alert_delivery"]["delivered"] = True
|
||||
summary["alert_delivery"]["suppressed"] = True
|
||||
elif not (failed or warnings) and args.alert_state_file:
|
||||
try:
|
||||
write_alert_state(args.alert_state_file, fingerprint=None, delivered_at=None)
|
||||
except OSError:
|
||||
alert_delivery_failed = True
|
||||
summary["alert_delivery"]["error"] = "StateWriteError"
|
||||
print("PRODUCTION_HEALTH_ALERT_STATE_FAIL", file=sys.stderr)
|
||||
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(
|
||||
f"PRODUCTION_HEALTH_{summary['status'].upper()} "
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
location ^~ /ops/alerts/ {
|
||||
client_max_body_size 1m;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_connect_timeout 3s;
|
||||
proxy_read_timeout 10s;
|
||||
proxy_pass http://127.0.0.1:19091;
|
||||
|
||||
limit_except POST {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Receive authenticated production health alerts and persist immutable receipts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
|
||||
TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{43,128}$")
|
||||
DEFAULT_MAX_BODY_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
def read_token(path: Path) -> str:
|
||||
token = path.read_text(encoding="utf-8").strip()
|
||||
if not TOKEN_PATTERN.fullmatch(token):
|
||||
raise ValueError("alert receiver token must be 43-128 URL-safe characters")
|
||||
return token
|
||||
|
||||
|
||||
def parse_alert_body(raw: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("alert body must be valid UTF-8 JSON") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("alert body must be a JSON object")
|
||||
if payload.get("status") not in {"warning", "fail"}:
|
||||
raise ValueError("alert status must be warning or fail")
|
||||
if not isinstance(payload.get("checked_at"), str):
|
||||
raise ValueError("alert checked_at is required")
|
||||
return payload
|
||||
|
||||
|
||||
def persist_event(
|
||||
event_dir: Path,
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
raw: bytes,
|
||||
source: str,
|
||||
) -> dict[str, str]:
|
||||
received_at = datetime.now(timezone.utc).isoformat()
|
||||
event_id = f"yuqei-alert-{uuid.uuid4().hex}"
|
||||
receipt = {
|
||||
"schema_version": "yuqei-production-alert-receipt.v1",
|
||||
"event_id": event_id,
|
||||
"received_at": received_at,
|
||||
"source": source,
|
||||
"body_sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"payload": payload,
|
||||
}
|
||||
event_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
target = event_dir / f"{event_id}.json"
|
||||
encoded = (json.dumps(receipt, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
||||
descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(encoded)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
except Exception:
|
||||
target.unlink(missing_ok=True)
|
||||
raise
|
||||
return {
|
||||
"event_id": event_id,
|
||||
"received_at": received_at,
|
||||
"body_sha256": receipt["body_sha256"],
|
||||
}
|
||||
|
||||
|
||||
class AlertReceiver(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_address: tuple[str, int],
|
||||
*,
|
||||
token: str,
|
||||
event_dir: Path,
|
||||
max_body_bytes: int,
|
||||
) -> None:
|
||||
super().__init__(server_address, AlertHandler)
|
||||
self.expected_path = f"/ops/alerts/{token}"
|
||||
self.event_dir = event_dir
|
||||
self.max_body_bytes = max_body_bytes
|
||||
|
||||
|
||||
class AlertHandler(BaseHTTPRequestHandler):
|
||||
server: AlertReceiver
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
request_path = unquote(urlparse(self.path).path)
|
||||
if not hmac.compare_digest(request_path, self.server.expected_path):
|
||||
self._respond(HTTPStatus.NOT_FOUND, {"error": "not_found"})
|
||||
return
|
||||
content_type = self.headers.get_content_type()
|
||||
if content_type != "application/json":
|
||||
self._respond(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, {"error": "application_json_required"})
|
||||
return
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", ""))
|
||||
except ValueError:
|
||||
content_length = -1
|
||||
if not 0 < content_length <= self.server.max_body_bytes:
|
||||
self._respond(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": "invalid_content_length"})
|
||||
return
|
||||
raw = self.rfile.read(content_length)
|
||||
if len(raw) != content_length:
|
||||
self._respond(HTTPStatus.BAD_REQUEST, {"error": "incomplete_body"})
|
||||
return
|
||||
try:
|
||||
payload = parse_alert_body(raw)
|
||||
receipt = persist_event(
|
||||
self.server.event_dir,
|
||||
payload=payload,
|
||||
raw=raw,
|
||||
source=self.headers.get("X-Forwarded-For", self.client_address[0]),
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
self._respond(HTTPStatus.BAD_REQUEST, {"error": "invalid_alert"})
|
||||
return
|
||||
self._respond(HTTPStatus.CREATED, receipt)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
self._respond(HTTPStatus.METHOD_NOT_ALLOWED, {"error": "method_not_allowed"})
|
||||
|
||||
def log_message(self, message: str, *args: object) -> None:
|
||||
print(f"ALERT_RECEIVER {self.address_string()} {message % args}", file=sys.stderr)
|
||||
|
||||
def _respond(self, status: HTTPStatus, payload: dict[str, str]) -> None:
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
|
||||
def build_server(
|
||||
bind: str,
|
||||
port: int,
|
||||
*,
|
||||
token_file: Path,
|
||||
event_dir: Path,
|
||||
max_body_bytes: int,
|
||||
) -> AlertReceiver:
|
||||
if not 0 <= port <= 65535:
|
||||
raise ValueError("port must be between 0 and 65535")
|
||||
if not 1024 <= max_body_bytes <= 4 * 1024 * 1024:
|
||||
raise ValueError("maximum body size must be between 1 KiB and 4 MiB")
|
||||
return AlertReceiver(
|
||||
(bind, port),
|
||||
token=read_token(token_file),
|
||||
event_dir=event_dir.resolve(),
|
||||
max_body_bytes=max_body_bytes,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--bind", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=19091)
|
||||
parser.add_argument("--token-file", type=Path, required=True)
|
||||
parser.add_argument("--event-dir", type=Path, required=True)
|
||||
parser.add_argument("--max-body-bytes", type=int, default=DEFAULT_MAX_BODY_BYTES)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
server = build_server(
|
||||
args.bind,
|
||||
args.port,
|
||||
token_file=args.token_file,
|
||||
event_dir=args.event_dir,
|
||||
max_body_bytes=args.max_body_bytes,
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f"ALERT_RECEIVER_FAIL error={type(exc).__name__}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"ALERT_RECEIVER_READY bind={args.bind} port={args.port}", flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,28 @@
|
||||
[Unit]
|
||||
Description=Yuqei production alert receipt receiver
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=yuqei-alert
|
||||
Group=yuqei-alert
|
||||
WorkingDirectory=/opt/yuqei-alert-receiver
|
||||
ExecStart=/usr/bin/python3 /opt/yuqei-alert-receiver/receive-production-alert.py --bind 127.0.0.1 --port 19091 --token-file /etc/yuqei/alert-receiver/token --event-dir /var/lib/yuqei-alert-receiver/events
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
UMask=0077
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
ReadOnlyPaths=/etc/yuqei/alert-receiver/token
|
||||
ReadWritePaths=/var/lib/yuqei-alert-receiver/events
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -8,7 +8,7 @@ Type=oneshot
|
||||
User=nobody
|
||||
Group=nobody
|
||||
WorkingDirectory=/opt/apps/ai-contract-platform-v2-production
|
||||
ExecStart=/usr/bin/python3.11 yuqei-infra/observability/check-production-health.py --edge-base-url ${BACKEND_PUBLIC_BASE_URL} --contract-api-base http://127.0.0.1:${CONTRACT_API_HOST_PORT} --contract-proxy-key-file /etc/yuqei/monitoring/contract-proxy-key --ai-api-base http://127.0.0.1:${AI_PLATFORM_API_HOST_PORT} --ai-service-key-file /etc/yuqei/monitoring/ai-platform-service-key --backup-root ${BACKUP_HOST_DIR} --backup-max-age-hours ${BACKUP_MAX_AGE_HOURS} --root-disk-path / --root-disk-warning-percent ${ROOT_DISK_WARNING_PERCENT} --root-disk-critical-percent ${ROOT_DISK_CRITICAL_PERCENT} --root-disk-warning-free-gib ${ROOT_DISK_WARNING_FREE_GIB} --root-disk-critical-free-gib ${ROOT_DISK_CRITICAL_FREE_GIB} --home-disk-path /home --home-disk-warning-percent ${HOME_DISK_WARNING_PERCENT} --home-disk-critical-percent ${HOME_DISK_CRITICAL_PERCENT} --home-disk-warning-free-gib ${HOME_DISK_WARNING_FREE_GIB} --home-disk-critical-free-gib ${HOME_DISK_CRITICAL_FREE_GIB} --home-require-separate-mount --backup-disk-path ${BACKUP_HOST_DIR} --backup-disk-warning-percent ${BACKUP_DISK_WARNING_PERCENT} --backup-disk-critical-percent ${BACKUP_DISK_CRITICAL_PERCENT} --backup-disk-warning-free-gib ${BACKUP_DISK_WARNING_FREE_GIB} --backup-require-separate-mount --timeout-seconds 30
|
||||
ExecStart=/usr/bin/python3.11 yuqei-infra/observability/check-production-health.py --edge-base-url ${BACKEND_PUBLIC_BASE_URL} --contract-api-base http://127.0.0.1:${CONTRACT_API_HOST_PORT} --contract-proxy-key-file /etc/yuqei/monitoring/contract-proxy-key --ai-api-base http://127.0.0.1:${AI_PLATFORM_API_HOST_PORT} --ai-service-key-file /etc/yuqei/monitoring/ai-platform-service-key --backup-root ${BACKUP_HOST_DIR} --backup-max-age-hours ${BACKUP_MAX_AGE_HOURS} --root-disk-path / --root-disk-warning-percent ${ROOT_DISK_WARNING_PERCENT} --root-disk-critical-percent ${ROOT_DISK_CRITICAL_PERCENT} --root-disk-warning-free-gib ${ROOT_DISK_WARNING_FREE_GIB} --root-disk-critical-free-gib ${ROOT_DISK_CRITICAL_FREE_GIB} --home-disk-path /home --home-disk-warning-percent ${HOME_DISK_WARNING_PERCENT} --home-disk-critical-percent ${HOME_DISK_CRITICAL_PERCENT} --home-disk-warning-free-gib ${HOME_DISK_WARNING_FREE_GIB} --home-disk-critical-free-gib ${HOME_DISK_CRITICAL_FREE_GIB} --home-require-separate-mount --backup-disk-path ${BACKUP_HOST_DIR} --backup-disk-warning-percent ${BACKUP_DISK_WARNING_PERCENT} --backup-disk-critical-percent ${BACKUP_DISK_CRITICAL_PERCENT} --backup-disk-warning-free-gib ${BACKUP_DISK_WARNING_FREE_GIB} --backup-disk-critical-free-gib ${BACKUP_DISK_CRITICAL_FREE_GIB} --backup-require-separate-mount --webhook-url-file ${PRODUCTION_ALERT_WEBHOOK_URL_FILE} --alert-state-file /opt/apps/ai-contract-platform-v2-production/project-docs/qa-artifacts/v2-production-health/alert-state.json --alert-resend-seconds 3600 --timeout-seconds 30
|
||||
EnvironmentFile=/etc/yuqei/production.env
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare, finalize, sign, and verify named legal approval for public policies."""
|
||||
"""Prepare, sign, and verify named legal approval and trust requests for public policies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -23,8 +23,18 @@ PENDING_PAYLOAD_SCHEMA = "yuqei-public-policy-approval-pending-v1"
|
||||
APPROVED_PAYLOAD_SCHEMA = "yuqei-public-policy-approval-payload-v1"
|
||||
APPROVAL_SCHEMA = "yuqei-public-policy-legal-approval-v1"
|
||||
KEYRING_SCHEMA = "yuqei-public-policy-legal-keyring-v1"
|
||||
TRUST_REQUEST_SCHEMA = "yuqei-public-policy-key-trust-request-v1"
|
||||
TRUST_APPROVAL_PAYLOAD_SCHEMA = "yuqei-public-policy-key-trust-approval-payload-v1"
|
||||
TRUST_APPROVAL_SCHEMA = "yuqei-public-policy-key-trust-approval-v1"
|
||||
SIGNATURE_DOMAIN = b"YUQEI_PUBLIC_POLICY_LEGAL_APPROVAL_V1\0"
|
||||
TRUST_SIGNATURE_DOMAIN = b"YUQEI_PUBLIC_POLICY_KEY_TRUST_APPROVAL_V1\0"
|
||||
SIGN_CONFIRMATION = "I_APPROVE_THIS_EXACT_POLICY_PAYLOAD"
|
||||
TRUST_SIGN_CONFIRMATION = "I_APPROVE_THIS_EXACT_POLICY_KEY_TRUST_PAYLOAD"
|
||||
TRUST_REVIEW_ATTESTATIONS = [
|
||||
"verified_legal_approver_identity_and_authority_out_of_band",
|
||||
"verified_legal_signing_key_fingerprint_out_of_band",
|
||||
"accepts_independent_security_or_operations_review_responsibility",
|
||||
]
|
||||
EXPECTED_POLICY_IDS = {
|
||||
"user_terms",
|
||||
"privacy_policy",
|
||||
@@ -409,9 +419,9 @@ def verify_approval(
|
||||
|
||||
if keyring.get("schema_version") != KEYRING_SCHEMA:
|
||||
raise PolicyApprovalError("policy keyring schema_version is invalid")
|
||||
if keyring.get("status") != "trusted" and not (
|
||||
allow_candidate_keyring and keyring.get("status") == "candidate_not_trusted"
|
||||
):
|
||||
if keyring.get("status") == "trusted":
|
||||
verify_trusted_keyring_basis(keyring, approval=approval)
|
||||
elif not (allow_candidate_keyring and keyring.get("status") == "candidate_not_trusted"):
|
||||
raise PolicyApprovalError("policy keyring is not trusted")
|
||||
keys = keyring.get("keys")
|
||||
if not isinstance(keys, list):
|
||||
@@ -459,6 +469,436 @@ def verify_approval(
|
||||
}
|
||||
|
||||
|
||||
def verify_trusted_keyring_basis(keyring: dict[str, Any], *, approval: dict[str, Any]) -> None:
|
||||
basis = keyring.get("trust_basis")
|
||||
if not isinstance(basis, dict):
|
||||
raise PolicyApprovalError("trusted policy keyring lacks an independently signed trust basis")
|
||||
if basis.get("policy_activation_authorized") is not False:
|
||||
raise PolicyApprovalError("trusted policy keyring must not authorize policy activation")
|
||||
trust_approval = basis.get("trust_approval")
|
||||
if not isinstance(trust_approval, dict) or trust_approval.get("schema_version") != TRUST_APPROVAL_SCHEMA:
|
||||
raise PolicyApprovalError("trusted policy keyring trust approval is invalid")
|
||||
payload = trust_approval.get("payload")
|
||||
if not isinstance(payload, dict) or payload.get("schema_version") != TRUST_APPROVAL_PAYLOAD_SCHEMA:
|
||||
raise PolicyApprovalError("trusted policy keyring trust payload is invalid")
|
||||
decision = payload.get("decision")
|
||||
trust_approver = payload.get("trust_approver")
|
||||
legal_approver = payload.get("legal_approver")
|
||||
if not isinstance(decision, dict) or not isinstance(trust_approver, dict) or not isinstance(legal_approver, dict):
|
||||
raise PolicyApprovalError("trusted policy keyring trust bindings are invalid")
|
||||
if decision.get("decision") != "approve_policy_signing_key_trust":
|
||||
raise PolicyApprovalError("trusted policy keyring trust decision is invalid")
|
||||
if decision.get("policy_activation_authorized") is not False:
|
||||
raise PolicyApprovalError("trusted policy keyring trust decision must not authorize policy activation")
|
||||
if decision.get("responsibility") not in {"security", "operations"}:
|
||||
raise PolicyApprovalError("trusted policy keyring trust responsibility is invalid")
|
||||
if decision.get("attestations") != TRUST_REVIEW_ATTESTATIONS:
|
||||
raise PolicyApprovalError("trusted policy keyring trust attestations are incomplete")
|
||||
if payload.get("policy_approval_id") != approval.get("approval_id") or payload.get(
|
||||
"policy_approval_sha256"
|
||||
) != sha256_bytes(canonical_json(approval)):
|
||||
raise PolicyApprovalError("trusted policy keyring does not authorize this exact policy approval")
|
||||
trusted_at = parse_timestamp(_required_text(basis, "trusted_at"), label="trusted_at")
|
||||
approved = parse_timestamp(_required_text(decision, "approved_at"), label="approved_at")
|
||||
expires = parse_timestamp(_required_text(decision, "expires_at"), label="expires_at")
|
||||
if trusted_at < approved or trusted_at > expires:
|
||||
raise PolicyApprovalError("trusted policy keyring was issued outside its approval window")
|
||||
candidate = {key: value for key, value in keyring.items() if key != "trust_basis"}
|
||||
candidate["status"] = "candidate_not_trusted"
|
||||
candidate_sha256 = sha256_bytes(canonical_json(candidate))
|
||||
if payload.get("candidate_keyring_sha256") != candidate_sha256:
|
||||
raise PolicyApprovalError("trusted policy keyring candidate binding does not match")
|
||||
if basis.get("trust_request_sha256") != payload.get("trust_request_sha256"):
|
||||
raise PolicyApprovalError("trusted policy keyring trust request binding does not match")
|
||||
if basis.get("trust_approval_sha256") != sha256_bytes(canonical_json(trust_approval)):
|
||||
raise PolicyApprovalError("trusted policy keyring trust approval hash does not match")
|
||||
keys = candidate.get("keys")
|
||||
if not isinstance(keys, list) or len(keys) != 1 or not isinstance(keys[0], dict):
|
||||
raise PolicyApprovalError("trusted policy keyring candidate key set is invalid")
|
||||
legal_key = keys[0]
|
||||
if trust_approver.get("approver_id") == legal_key.get("approver_id"):
|
||||
raise PolicyApprovalError("trusted policy keyring approvers are not independent")
|
||||
if trust_approver.get("public_key_raw_sha256") == legal_key.get("public_key_raw_sha256"):
|
||||
raise PolicyApprovalError("trusted policy keyring approval keys are not independent")
|
||||
public_pem = basis.get("trust_approver_public_key_pem")
|
||||
if not isinstance(public_pem, str) or not public_pem.strip():
|
||||
raise PolicyApprovalError("trusted policy keyring trust approver public key is invalid")
|
||||
try:
|
||||
public_key = serialization.load_pem_public_key(public_pem.encode("ascii"))
|
||||
except (UnicodeError, ValueError) as exc:
|
||||
raise PolicyApprovalError("trusted policy keyring trust approver public key is invalid") from exc
|
||||
if not isinstance(public_key, Ed25519PublicKey):
|
||||
raise PolicyApprovalError("trusted policy keyring trust approver key must be Ed25519")
|
||||
public_raw = public_key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||
if sha256_bytes(public_pem.encode("ascii")) != trust_approver.get("public_key_file_sha256") or sha256_bytes(
|
||||
public_raw
|
||||
) != trust_approver.get("public_key_raw_sha256"):
|
||||
raise PolicyApprovalError("trusted policy keyring trust approver key binding does not match")
|
||||
try:
|
||||
signature = base64.b64decode(_required_text(trust_approval, "signature"), validate=True)
|
||||
public_key.verify(signature, TRUST_SIGNATURE_DOMAIN + canonical_json(payload))
|
||||
except Exception as exc:
|
||||
raise PolicyApprovalError("trusted policy keyring trust approval signature verification failed") from exc
|
||||
expected_id = f"policy-key-trust-approval-{sha256_bytes(canonical_json(payload) + signature)[:24]}"
|
||||
if trust_approval.get("trust_approval_id") != expected_id or basis.get("trust_approval_id") != expected_id:
|
||||
raise PolicyApprovalError("trusted policy keyring trust approval_id does not match")
|
||||
|
||||
|
||||
def build_trust_request(
|
||||
approval: dict[str, Any],
|
||||
*,
|
||||
candidate_keyring: dict[str, Any],
|
||||
policy_dir: Path,
|
||||
requested_at: str,
|
||||
) -> dict[str, Any]:
|
||||
if candidate_keyring.get("schema_version") != KEYRING_SCHEMA:
|
||||
raise PolicyApprovalError("candidate policy keyring schema_version is invalid")
|
||||
if candidate_keyring.get("status") != "candidate_not_trusted":
|
||||
raise PolicyApprovalError("trust request requires a candidate_not_trusted keyring")
|
||||
requested_at = normalized_timestamp(requested_at, label="requested_at")
|
||||
verified = verify_approval(
|
||||
approval,
|
||||
keyring=candidate_keyring,
|
||||
policy_dir=policy_dir,
|
||||
checked_at=requested_at,
|
||||
allow_candidate_keyring=True,
|
||||
)
|
||||
payload = approval["payload"]
|
||||
request = payload["approval_request"]
|
||||
decision = payload["decision"]
|
||||
return {
|
||||
"schema_version": TRUST_REQUEST_SCHEMA,
|
||||
"status": "pending_independent_trust_approval",
|
||||
"requested_at": requested_at,
|
||||
"activation_authorized": False,
|
||||
"approval_id": verified["approval_id"],
|
||||
"approval_sha256": sha256_bytes(canonical_json(approval)),
|
||||
"candidate_keyring_sha256": sha256_bytes(canonical_json(candidate_keyring)),
|
||||
"policy_version": verified["policy_version"],
|
||||
"effective_at": verified["effective_at"],
|
||||
"approval_expires_at": normalized_timestamp(
|
||||
_required_text(decision, "expires_at"),
|
||||
label="expires_at",
|
||||
),
|
||||
"documents": verified["documents"],
|
||||
"key": {
|
||||
field: request[field]
|
||||
for field in (
|
||||
"key_id",
|
||||
"algorithm",
|
||||
"approver_id",
|
||||
"reviewer",
|
||||
"role",
|
||||
"organization",
|
||||
"public_key_file_sha256",
|
||||
"public_key_raw_sha256",
|
||||
)
|
||||
},
|
||||
"required_independent_actions": [
|
||||
"verify_approver_identity_and_authority_out_of_band",
|
||||
"verify_public_key_fingerprint_out_of_band",
|
||||
"record_named_security_or_operations_approval",
|
||||
"install_a_separately_approved_trusted_keyring",
|
||||
"rerun_policy_approval_verification_with_candidate_override_disabled",
|
||||
],
|
||||
"prohibited_automatic_actions": [
|
||||
"mark_candidate_keyring_trusted",
|
||||
"publish_policy_documents",
|
||||
"change_policy_lifecycle_to_effective",
|
||||
"start_edge",
|
||||
"change_public_traffic",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def validate_trust_request(
|
||||
trust_request: dict[str, Any],
|
||||
*,
|
||||
approval: dict[str, Any],
|
||||
candidate_keyring: dict[str, Any],
|
||||
policy_dir: Path,
|
||||
checked_at: str,
|
||||
) -> dict[str, Any]:
|
||||
if trust_request.get("schema_version") != TRUST_REQUEST_SCHEMA:
|
||||
raise PolicyApprovalError("policy trust request schema_version is invalid")
|
||||
if trust_request.get("status") != "pending_independent_trust_approval":
|
||||
raise PolicyApprovalError("policy trust request is not pending independent approval")
|
||||
if trust_request.get("activation_authorized") is not False:
|
||||
raise PolicyApprovalError("policy trust request must not authorize activation")
|
||||
requested_at = _required_text(trust_request, "requested_at")
|
||||
expected = build_trust_request(
|
||||
approval,
|
||||
candidate_keyring=candidate_keyring,
|
||||
policy_dir=policy_dir,
|
||||
requested_at=requested_at,
|
||||
)
|
||||
if canonical_json(trust_request) != canonical_json(expected):
|
||||
raise PolicyApprovalError("policy trust request does not match its signed source material")
|
||||
checked = parse_timestamp(checked_at, label="checked_at")
|
||||
if checked < parse_timestamp(requested_at, label="requested_at"):
|
||||
raise PolicyApprovalError("policy trust request cannot be checked before it was requested")
|
||||
verify_approval(
|
||||
approval,
|
||||
keyring=candidate_keyring,
|
||||
policy_dir=policy_dir,
|
||||
checked_at=checked.isoformat(),
|
||||
allow_candidate_keyring=True,
|
||||
)
|
||||
return expected
|
||||
|
||||
|
||||
def build_trust_approval_payload(
|
||||
trust_request: dict[str, Any],
|
||||
*,
|
||||
approval: dict[str, Any],
|
||||
candidate_keyring: dict[str, Any],
|
||||
policy_dir: Path,
|
||||
trust_approver_public_key_file: Path,
|
||||
key_id: str,
|
||||
approver_id: str,
|
||||
reviewer: str,
|
||||
role: str,
|
||||
organization: str,
|
||||
responsibility: str,
|
||||
approved_at: str,
|
||||
expires_at: str,
|
||||
key_not_before: str,
|
||||
key_not_after: str,
|
||||
) -> dict[str, Any]:
|
||||
if not KEY_ID_PATTERN.fullmatch(key_id):
|
||||
raise PolicyApprovalError("trust approver key_id format is invalid")
|
||||
approved_at = normalized_timestamp(approved_at, label="approved_at")
|
||||
expires_at = normalized_timestamp(expires_at, label="expires_at")
|
||||
key_not_before = normalized_timestamp(key_not_before, label="key_not_before")
|
||||
key_not_after = normalized_timestamp(key_not_after, label="key_not_after")
|
||||
approved = parse_timestamp(approved_at, label="approved_at")
|
||||
expires = parse_timestamp(expires_at, label="expires_at")
|
||||
if expires <= approved or expires - approved > MAX_APPROVAL_WINDOW:
|
||||
raise PolicyApprovalError("trust approval validity must be greater than zero and no longer than seven days")
|
||||
if approved < parse_timestamp(key_not_before, label="key_not_before") or expires > parse_timestamp(
|
||||
key_not_after, label="key_not_after"
|
||||
):
|
||||
raise PolicyApprovalError("trust approval window must remain within the trust key validity window")
|
||||
request = validate_trust_request(
|
||||
trust_request,
|
||||
approval=approval,
|
||||
candidate_keyring=candidate_keyring,
|
||||
policy_dir=policy_dir,
|
||||
checked_at=approved_at,
|
||||
)
|
||||
legal_expiry = parse_timestamp(_required_text(request, "approval_expires_at"), label="approval_expires_at")
|
||||
if expires > legal_expiry:
|
||||
raise PolicyApprovalError("trust approval cannot outlive the legal policy approval")
|
||||
_, public_pem, public_raw = load_public_key(trust_approver_public_key_file)
|
||||
identity = {
|
||||
"approver_id": _nonempty(approver_id, "trust approver_id"),
|
||||
"reviewer": _nonempty(reviewer, "trust reviewer"),
|
||||
"role": _nonempty(role, "trust role"),
|
||||
"organization": _nonempty(organization, "trust organization"),
|
||||
}
|
||||
responsibility = _nonempty(responsibility, "trust responsibility").lower()
|
||||
if responsibility not in {"security", "operations"}:
|
||||
raise PolicyApprovalError("trust responsibility must be security or operations")
|
||||
legal_key = request.get("key")
|
||||
if not isinstance(legal_key, dict):
|
||||
raise PolicyApprovalError("policy trust request legal key binding is invalid")
|
||||
public_raw_sha256 = sha256_bytes(public_raw)
|
||||
if identity["approver_id"] == legal_key.get("approver_id"):
|
||||
raise PolicyApprovalError("trust approver must differ from the legal approver")
|
||||
if public_raw_sha256 == legal_key.get("public_key_raw_sha256"):
|
||||
raise PolicyApprovalError("trust approver key must differ from the legal approval key")
|
||||
return {
|
||||
"schema_version": TRUST_APPROVAL_PAYLOAD_SCHEMA,
|
||||
"status": "pending_independent_trust_signature",
|
||||
"trust_request_sha256": sha256_bytes(canonical_json(request)),
|
||||
"policy_approval_id": request["approval_id"],
|
||||
"policy_approval_sha256": request["approval_sha256"],
|
||||
"candidate_keyring_sha256": request["candidate_keyring_sha256"],
|
||||
"policy_version": request["policy_version"],
|
||||
"effective_at": request["effective_at"],
|
||||
"documents": request["documents"],
|
||||
"legal_approver": {
|
||||
field: legal_key[field]
|
||||
for field in ("approver_id", "reviewer", "role", "organization", "key_id", "public_key_raw_sha256")
|
||||
},
|
||||
"trust_approver": {
|
||||
**identity,
|
||||
"key_id": key_id,
|
||||
"algorithm": "Ed25519",
|
||||
"public_key_file_sha256": sha256_bytes(public_pem),
|
||||
"public_key_raw_sha256": public_raw_sha256,
|
||||
"key_not_before": key_not_before,
|
||||
"key_not_after": key_not_after,
|
||||
},
|
||||
"decision": {
|
||||
"decision": "approve_policy_signing_key_trust",
|
||||
"responsibility": responsibility,
|
||||
"attestations": TRUST_REVIEW_ATTESTATIONS,
|
||||
"approved_at": approved_at,
|
||||
"expires_at": expires_at,
|
||||
"confirmation": TRUST_SIGN_CONFIRMATION,
|
||||
"policy_activation_authorized": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def sign_trust_approval_payload(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
private_key_file: Path,
|
||||
password_file: Path,
|
||||
confirmation: str,
|
||||
) -> dict[str, Any]:
|
||||
if confirmation != TRUST_SIGN_CONFIRMATION:
|
||||
raise PolicyApprovalError(f"trust signing requires confirmation {TRUST_SIGN_CONFIRMATION}")
|
||||
if payload.get("schema_version") != TRUST_APPROVAL_PAYLOAD_SCHEMA:
|
||||
raise PolicyApprovalError("trust approval payload schema_version is invalid")
|
||||
if payload.get("status") != "pending_independent_trust_signature":
|
||||
raise PolicyApprovalError("only a pending trust approval payload can be signed")
|
||||
decision = payload.get("decision")
|
||||
if not isinstance(decision, dict) or decision.get("policy_activation_authorized") is not False:
|
||||
raise PolicyApprovalError("trust approval payload must not authorize policy activation")
|
||||
private_raw = read_bounded(private_key_file, label="trust approver private key", max_bytes=64 * 1024)
|
||||
password = read_bounded(password_file, label="trust approver private key password", max_bytes=4096).strip()
|
||||
if not password:
|
||||
raise PolicyApprovalError("trust approver private key password is empty")
|
||||
try:
|
||||
key = serialization.load_pem_private_key(private_raw, password=password)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise PolicyApprovalError("trust approver private key or password is invalid") from exc
|
||||
if not isinstance(key, Ed25519PrivateKey):
|
||||
raise PolicyApprovalError("trust approver private key must be Ed25519")
|
||||
public_raw = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||
trust_approver = payload.get("trust_approver")
|
||||
if not isinstance(trust_approver, dict) or sha256_bytes(public_raw) != trust_approver.get(
|
||||
"public_key_raw_sha256"
|
||||
):
|
||||
raise PolicyApprovalError("trust approver private key does not match the payload binding")
|
||||
payload_raw = canonical_json(payload)
|
||||
signature = key.sign(TRUST_SIGNATURE_DOMAIN + payload_raw)
|
||||
approval_id = f"policy-key-trust-approval-{sha256_bytes(payload_raw + signature)[:24]}"
|
||||
return {
|
||||
"schema_version": TRUST_APPROVAL_SCHEMA,
|
||||
"trust_approval_id": approval_id,
|
||||
"payload": payload,
|
||||
"signature": base64.b64encode(signature).decode("ascii"),
|
||||
}
|
||||
|
||||
|
||||
def issue_trusted_keyring(
|
||||
trust_approval: dict[str, Any],
|
||||
*,
|
||||
trust_request: dict[str, Any],
|
||||
approval: dict[str, Any],
|
||||
candidate_keyring: dict[str, Any],
|
||||
policy_dir: Path,
|
||||
trust_approver_public_key_file: Path,
|
||||
checked_at: str,
|
||||
) -> dict[str, Any]:
|
||||
if trust_approval.get("schema_version") != TRUST_APPROVAL_SCHEMA:
|
||||
raise PolicyApprovalError("policy key trust approval schema_version is invalid")
|
||||
payload = trust_approval.get("payload")
|
||||
if not isinstance(payload, dict) or payload.get("schema_version") != TRUST_APPROVAL_PAYLOAD_SCHEMA:
|
||||
raise PolicyApprovalError("signed policy key trust approval payload is invalid")
|
||||
checked_at = normalized_timestamp(checked_at, label="checked_at")
|
||||
request = validate_trust_request(
|
||||
trust_request,
|
||||
approval=approval,
|
||||
candidate_keyring=candidate_keyring,
|
||||
policy_dir=policy_dir,
|
||||
checked_at=checked_at,
|
||||
)
|
||||
expected_bindings = {
|
||||
"trust_request_sha256": sha256_bytes(canonical_json(request)),
|
||||
"policy_approval_id": request["approval_id"],
|
||||
"policy_approval_sha256": request["approval_sha256"],
|
||||
"candidate_keyring_sha256": request["candidate_keyring_sha256"],
|
||||
"policy_version": request["policy_version"],
|
||||
"effective_at": request["effective_at"],
|
||||
"documents": request["documents"],
|
||||
}
|
||||
for field, expected in expected_bindings.items():
|
||||
if payload.get(field) != expected:
|
||||
raise PolicyApprovalError(f"trust approval binding mismatch: {field}")
|
||||
decision = payload.get("decision")
|
||||
if not isinstance(decision, dict) or decision.get("decision") != "approve_policy_signing_key_trust":
|
||||
raise PolicyApprovalError("policy key trust decision is invalid")
|
||||
if decision.get("responsibility") not in {"security", "operations"}:
|
||||
raise PolicyApprovalError("policy key trust responsibility is invalid")
|
||||
if decision.get("attestations") != TRUST_REVIEW_ATTESTATIONS:
|
||||
raise PolicyApprovalError("policy key trust attestations are incomplete")
|
||||
if decision.get("policy_activation_authorized") is not False:
|
||||
raise PolicyApprovalError("policy key trust approval must not authorize policy activation")
|
||||
checked = parse_timestamp(checked_at, label="checked_at")
|
||||
approved = parse_timestamp(_required_text(decision, "approved_at"), label="approved_at")
|
||||
expires = parse_timestamp(_required_text(decision, "expires_at"), label="expires_at")
|
||||
if checked < approved or checked > expires:
|
||||
raise PolicyApprovalError("policy key trust approval is not valid at checked_at")
|
||||
trust_approver = payload.get("trust_approver")
|
||||
legal_approver = payload.get("legal_approver")
|
||||
if not isinstance(trust_approver, dict) or not isinstance(legal_approver, dict):
|
||||
raise PolicyApprovalError("policy key trust approver bindings are invalid")
|
||||
expected_payload = build_trust_approval_payload(
|
||||
trust_request,
|
||||
approval=approval,
|
||||
candidate_keyring=candidate_keyring,
|
||||
policy_dir=policy_dir,
|
||||
trust_approver_public_key_file=trust_approver_public_key_file,
|
||||
key_id=_required_text(trust_approver, "key_id"),
|
||||
approver_id=_required_text(trust_approver, "approver_id"),
|
||||
reviewer=_required_text(trust_approver, "reviewer"),
|
||||
role=_required_text(trust_approver, "role"),
|
||||
organization=_required_text(trust_approver, "organization"),
|
||||
responsibility=_required_text(decision, "responsibility"),
|
||||
approved_at=_required_text(decision, "approved_at"),
|
||||
expires_at=_required_text(decision, "expires_at"),
|
||||
key_not_before=_required_text(trust_approver, "key_not_before"),
|
||||
key_not_after=_required_text(trust_approver, "key_not_after"),
|
||||
)
|
||||
if canonical_json(payload) != canonical_json(expected_payload):
|
||||
raise PolicyApprovalError("policy key trust approval payload does not match its source material")
|
||||
public_key, public_pem, public_raw = load_public_key(trust_approver_public_key_file)
|
||||
if sha256_bytes(public_pem) != trust_approver.get("public_key_file_sha256") or sha256_bytes(
|
||||
public_raw
|
||||
) != trust_approver.get("public_key_raw_sha256"):
|
||||
raise PolicyApprovalError("trust approver public key does not match the signed payload")
|
||||
if sha256_bytes(public_raw) == legal_approver.get("public_key_raw_sha256"):
|
||||
raise PolicyApprovalError("trust approver key must differ from the legal approval key")
|
||||
try:
|
||||
signature = base64.b64decode(_required_text(trust_approval, "signature"), validate=True)
|
||||
public_key.verify(signature, TRUST_SIGNATURE_DOMAIN + canonical_json(payload))
|
||||
except Exception as exc:
|
||||
raise PolicyApprovalError("policy key trust approval signature verification failed") from exc
|
||||
expected_id = f"policy-key-trust-approval-{sha256_bytes(canonical_json(payload) + signature)[:24]}"
|
||||
if trust_approval.get("trust_approval_id") != expected_id:
|
||||
raise PolicyApprovalError("policy key trust approval_id does not match its signed material")
|
||||
return {
|
||||
**candidate_keyring,
|
||||
"status": "trusted",
|
||||
"trust_basis": {
|
||||
"trust_approval_id": expected_id,
|
||||
"trust_approval_sha256": sha256_bytes(canonical_json(trust_approval)),
|
||||
"trust_request_sha256": expected_bindings["trust_request_sha256"],
|
||||
"trusted_at": checked_at,
|
||||
"trust_approval": trust_approval,
|
||||
"trust_approver_public_key_pem": public_pem.decode("ascii"),
|
||||
"trust_approver": {
|
||||
field: trust_approver[field]
|
||||
for field in (
|
||||
"approver_id",
|
||||
"reviewer",
|
||||
"role",
|
||||
"organization",
|
||||
"key_id",
|
||||
"public_key_file_sha256",
|
||||
"public_key_raw_sha256",
|
||||
)
|
||||
},
|
||||
"policy_activation_authorized": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _required_text(value: dict[str, Any], key: str) -> str:
|
||||
return _nonempty(value.get(key), key)
|
||||
|
||||
@@ -538,6 +978,97 @@ def _verify_command(args: argparse.Namespace) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
def _prepare_trust_request_command(args: argparse.Namespace) -> dict[str, Any]:
|
||||
approval = _load_json_file(args.approval_file, label="policy approval")
|
||||
keyring = _load_json_file(args.candidate_keyring_file, label="candidate policy keyring")
|
||||
request = build_trust_request(
|
||||
approval,
|
||||
candidate_keyring=keyring,
|
||||
policy_dir=args.policy_dir,
|
||||
requested_at=args.requested_at or datetime.now(timezone.utc).astimezone().isoformat(),
|
||||
)
|
||||
raw = canonical_json(request)
|
||||
write_atomic(args.output, raw)
|
||||
return {
|
||||
"status": request["status"],
|
||||
"trust_request_sha256": sha256_bytes(raw),
|
||||
"approval_id": request["approval_id"],
|
||||
"activation_authorized": False,
|
||||
}
|
||||
|
||||
|
||||
def _prepare_trust_approval_command(args: argparse.Namespace) -> dict[str, Any]:
|
||||
trust_request = _load_json_file(args.trust_request_file, label="policy trust request")
|
||||
approval = _load_json_file(args.approval_file, label="policy approval")
|
||||
keyring = _load_json_file(args.candidate_keyring_file, label="candidate policy keyring")
|
||||
payload = build_trust_approval_payload(
|
||||
trust_request,
|
||||
approval=approval,
|
||||
candidate_keyring=keyring,
|
||||
policy_dir=args.policy_dir,
|
||||
trust_approver_public_key_file=args.trust_approver_public_key_file,
|
||||
key_id=args.key_id,
|
||||
approver_id=args.approver_id,
|
||||
reviewer=args.reviewer,
|
||||
role=args.role,
|
||||
organization=args.organization,
|
||||
responsibility=args.responsibility,
|
||||
approved_at=args.approved_at,
|
||||
expires_at=args.expires_at,
|
||||
key_not_before=args.key_not_before,
|
||||
key_not_after=args.key_not_after,
|
||||
)
|
||||
raw = canonical_json(payload)
|
||||
write_atomic(args.output, raw)
|
||||
return {
|
||||
"status": payload["status"],
|
||||
"trust_approval_payload_sha256": sha256_bytes(raw),
|
||||
"activation_authorized": False,
|
||||
}
|
||||
|
||||
|
||||
def _sign_trust_approval_command(args: argparse.Namespace) -> dict[str, Any]:
|
||||
payload = _load_json_file(args.payload_file, label="policy trust approval payload")
|
||||
trust_approval = sign_trust_approval_payload(
|
||||
payload,
|
||||
private_key_file=args.private_key_file,
|
||||
password_file=args.private_key_password_file,
|
||||
confirmation=args.confirmation,
|
||||
)
|
||||
raw = canonical_json(trust_approval)
|
||||
write_atomic(args.output, raw, private=True)
|
||||
return {
|
||||
"status": "signed_independent_trust_approval",
|
||||
"trust_approval_id": trust_approval["trust_approval_id"],
|
||||
"trust_approval_sha256": sha256_bytes(raw),
|
||||
"activation_authorized": False,
|
||||
}
|
||||
|
||||
|
||||
def _issue_trusted_keyring_command(args: argparse.Namespace) -> dict[str, Any]:
|
||||
trust_approval = _load_json_file(args.trust_approval_file, label="policy key trust approval")
|
||||
trust_request = _load_json_file(args.trust_request_file, label="policy trust request")
|
||||
approval = _load_json_file(args.approval_file, label="policy approval")
|
||||
keyring = _load_json_file(args.candidate_keyring_file, label="candidate policy keyring")
|
||||
trusted = issue_trusted_keyring(
|
||||
trust_approval,
|
||||
trust_request=trust_request,
|
||||
approval=approval,
|
||||
candidate_keyring=keyring,
|
||||
policy_dir=args.policy_dir,
|
||||
trust_approver_public_key_file=args.trust_approver_public_key_file,
|
||||
checked_at=args.checked_at or datetime.now(timezone.utc).astimezone().isoformat(),
|
||||
)
|
||||
raw = canonical_json(trusted)
|
||||
write_atomic(args.output, raw)
|
||||
return {
|
||||
"status": "trusted_keyring_issued_policy_not_activated",
|
||||
"trusted_keyring_sha256": sha256_bytes(raw),
|
||||
"trust_approval_id": trusted["trust_basis"]["trust_approval_id"],
|
||||
"activation_authorized": False,
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
@@ -576,6 +1107,60 @@ def parse_args() -> argparse.Namespace:
|
||||
verify.add_argument("--policy-dir", type=Path, required=True)
|
||||
verify.add_argument("--checked-at")
|
||||
verify.add_argument("--allow-candidate-keyring", action="store_true")
|
||||
|
||||
trust = commands.add_parser(
|
||||
"prepare-trust-request",
|
||||
help="bind a verified signed approval to a candidate keyring without granting trust",
|
||||
)
|
||||
trust.add_argument("--approval-file", type=Path, required=True)
|
||||
trust.add_argument("--candidate-keyring-file", type=Path, required=True)
|
||||
trust.add_argument("--policy-dir", type=Path, required=True)
|
||||
trust.add_argument("--requested-at")
|
||||
trust.add_argument("--output", type=Path, required=True)
|
||||
|
||||
trust_prepare = commands.add_parser(
|
||||
"prepare-trust-approval",
|
||||
help="create the exact independent security or operations approval payload",
|
||||
)
|
||||
trust_prepare.add_argument("--trust-request-file", type=Path, required=True)
|
||||
trust_prepare.add_argument("--approval-file", type=Path, required=True)
|
||||
trust_prepare.add_argument("--candidate-keyring-file", type=Path, required=True)
|
||||
trust_prepare.add_argument("--policy-dir", type=Path, required=True)
|
||||
trust_prepare.add_argument("--trust-approver-public-key-file", type=Path, required=True)
|
||||
trust_prepare.add_argument("--key-id", required=True)
|
||||
trust_prepare.add_argument("--approver-id", required=True)
|
||||
trust_prepare.add_argument("--reviewer", required=True)
|
||||
trust_prepare.add_argument("--role", required=True)
|
||||
trust_prepare.add_argument("--organization", required=True)
|
||||
trust_prepare.add_argument("--responsibility", choices=("security", "operations"), required=True)
|
||||
trust_prepare.add_argument("--approved-at", required=True)
|
||||
trust_prepare.add_argument("--expires-at", required=True)
|
||||
trust_prepare.add_argument("--key-not-before", required=True)
|
||||
trust_prepare.add_argument("--key-not-after", required=True)
|
||||
trust_prepare.add_argument("--output", type=Path, required=True)
|
||||
|
||||
trust_sign = commands.add_parser(
|
||||
"sign-trust-approval",
|
||||
help="sign an exact trust approval payload with the independent approver key",
|
||||
)
|
||||
trust_sign.add_argument("--payload-file", type=Path, required=True)
|
||||
trust_sign.add_argument("--private-key-file", type=Path, required=True)
|
||||
trust_sign.add_argument("--private-key-password-file", type=Path, required=True)
|
||||
trust_sign.add_argument("--confirmation", required=True)
|
||||
trust_sign.add_argument("--output", type=Path, required=True)
|
||||
|
||||
trust_issue = commands.add_parser(
|
||||
"issue-trusted-keyring",
|
||||
help="verify both approvals and issue a trusted keyring without activating policies",
|
||||
)
|
||||
trust_issue.add_argument("--trust-approval-file", type=Path, required=True)
|
||||
trust_issue.add_argument("--trust-request-file", type=Path, required=True)
|
||||
trust_issue.add_argument("--approval-file", type=Path, required=True)
|
||||
trust_issue.add_argument("--candidate-keyring-file", type=Path, required=True)
|
||||
trust_issue.add_argument("--policy-dir", type=Path, required=True)
|
||||
trust_issue.add_argument("--trust-approver-public-key-file", type=Path, required=True)
|
||||
trust_issue.add_argument("--checked-at")
|
||||
trust_issue.add_argument("--output", type=Path, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -588,6 +1173,14 @@ def main() -> int:
|
||||
result = _finalize_command(args)
|
||||
elif args.command == "sign":
|
||||
result = _sign_command(args)
|
||||
elif args.command == "prepare-trust-request":
|
||||
result = _prepare_trust_request_command(args)
|
||||
elif args.command == "prepare-trust-approval":
|
||||
result = _prepare_trust_approval_command(args)
|
||||
elif args.command == "sign-trust-approval":
|
||||
result = _sign_trust_approval_command(args)
|
||||
elif args.command == "issue-trusted-keyring":
|
||||
result = _issue_trusted_keyring_command(args)
|
||||
else:
|
||||
result = _verify_command(args)
|
||||
except (OSError, PolicyApprovalError) as exc:
|
||||
|
||||
@@ -10,6 +10,7 @@ import re
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Literal
|
||||
from urllib.error import HTTPError, URLError
|
||||
@@ -40,6 +41,10 @@ class NodeObservation:
|
||||
target_image_digest: str | None
|
||||
provider_problems: str
|
||||
kill_switch_active: bool
|
||||
environment: str | None = None
|
||||
generated_at: str | None = None
|
||||
verification_mode: str | None = None
|
||||
promotion_diagnostics_consistent: bool = False
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@@ -58,6 +63,8 @@ def fetch_node_readiness(
|
||||
method="GET",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Cache-Control": "no-cache, no-store",
|
||||
"Pragma": "no-cache",
|
||||
"User-Agent": "yuqei-provider-promotion-canary/1",
|
||||
**({"X-AI-Platform-Service-Key": service_key} if service_key else {}),
|
||||
},
|
||||
@@ -89,17 +96,44 @@ def parse_readiness_observation(node: str, payload: dict[str, Any]) -> NodeObser
|
||||
kill_switch = _check_by_code(checks, "provider_promotion_kill_switch")
|
||||
provider_details = provider.get("details") if isinstance(provider.get("details"), dict) else {}
|
||||
kill_details = kill_switch.get("details") if isinstance(kill_switch.get("details"), dict) else {}
|
||||
diagnostics = payload.get("promotion_diagnostics")
|
||||
if not isinstance(diagnostics, dict):
|
||||
raise ValueError("promotion diagnostics are missing")
|
||||
environment = _optional_string(payload.get("environment"))
|
||||
generated_at = _parse_aware_datetime(payload.get("generated_at"), label="generated_at").isoformat()
|
||||
verification_mode = _optional_string(diagnostics.get("verification_mode"))
|
||||
provider_verified = payload.get("provider_acceptance_verified") is True
|
||||
kill_switch_active = kill_details.get("active") is True
|
||||
diagnostic_bindings = (
|
||||
("release_id", "target_release_id"),
|
||||
("image_digest", "target_image_digest"),
|
||||
("receipt_id", "receipt_id"),
|
||||
("key_id", "key_id"),
|
||||
)
|
||||
diagnostics_consistent = (
|
||||
diagnostics.get("verified") is provider_verified
|
||||
and diagnostics.get("kill_switch_active") is kill_switch_active
|
||||
and all(
|
||||
provider_details.get(detail_key) is None
|
||||
or diagnostics.get(diagnostic_key) == provider_details.get(detail_key)
|
||||
for diagnostic_key, detail_key in diagnostic_bindings
|
||||
)
|
||||
)
|
||||
return NodeObservation(
|
||||
node=node,
|
||||
readiness_status=str(payload.get("status")) if payload.get("status") is not None else None,
|
||||
release_allowed=payload.get("release_allowed") is True,
|
||||
provider_acceptance_verified=payload.get("provider_acceptance_verified") is True,
|
||||
provider_acceptance_verified=provider_verified,
|
||||
receipt_id=_optional_string(provider_details.get("receipt_id")),
|
||||
key_id=_optional_string(provider_details.get("key_id")),
|
||||
target_release_id=_optional_string(provider_details.get("target_release_id")),
|
||||
target_image_digest=_optional_string(provider_details.get("target_image_digest")),
|
||||
provider_problems=_optional_string(provider_details.get("problems")) or "",
|
||||
kill_switch_active=kill_details.get("active") is True,
|
||||
kill_switch_active=kill_switch_active,
|
||||
environment=environment,
|
||||
generated_at=generated_at,
|
||||
verification_mode=verification_mode,
|
||||
promotion_diagnostics_consistent=diagnostics_consistent,
|
||||
)
|
||||
|
||||
|
||||
@@ -110,8 +144,14 @@ def verify_canary(
|
||||
expected_receipt_id: str,
|
||||
expected_key_id: str,
|
||||
expected_image_digest: str,
|
||||
max_observation_age_seconds: float = 120.0,
|
||||
now: datetime | None = None,
|
||||
) -> list[str]:
|
||||
problems: list[str] = []
|
||||
problems = verify_observation_provenance(
|
||||
observations,
|
||||
max_age_seconds=max_observation_age_seconds,
|
||||
now=now,
|
||||
)
|
||||
for item in observations:
|
||||
if item.error:
|
||||
problems.append(f"{item.node}: {item.error}")
|
||||
@@ -139,6 +179,38 @@ def verify_canary(
|
||||
return problems
|
||||
|
||||
|
||||
def verify_observation_provenance(
|
||||
observations: list[NodeObservation],
|
||||
*,
|
||||
max_age_seconds: float,
|
||||
now: datetime | None = None,
|
||||
) -> list[str]:
|
||||
observed_at = now or datetime.now(UTC)
|
||||
if observed_at.tzinfo is None:
|
||||
raise ValueError("verification time must include a timezone")
|
||||
problems: list[str] = []
|
||||
for item in observations:
|
||||
if item.error:
|
||||
continue
|
||||
if item.environment != "production":
|
||||
problems.append(f"{item.node}: readiness is not from production")
|
||||
if item.verification_mode != "offline_read_only":
|
||||
problems.append(f"{item.node}: readiness is not offline read-only verification")
|
||||
if not item.promotion_diagnostics_consistent:
|
||||
problems.append(f"{item.node}: promotion diagnostics are inconsistent")
|
||||
try:
|
||||
generated_at = _parse_aware_datetime(item.generated_at, label="generated_at")
|
||||
except ValueError:
|
||||
problems.append(f"{item.node}: readiness generation time is invalid")
|
||||
continue
|
||||
age_seconds = (observed_at.astimezone(UTC) - generated_at.astimezone(UTC)).total_seconds()
|
||||
if age_seconds < -30:
|
||||
problems.append(f"{item.node}: readiness generation time is in the future")
|
||||
elif age_seconds > max_age_seconds:
|
||||
problems.append(f"{item.node}: readiness response is stale")
|
||||
return problems
|
||||
|
||||
|
||||
def wait_for_control(
|
||||
nodes: list[str],
|
||||
*,
|
||||
@@ -197,8 +269,32 @@ def _optional_string(value: Any) -> str | None:
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _parse_aware_datetime(value: Any, *, label: str) -> datetime:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{label} is missing")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise ValueError(f"{label} is invalid") from None
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError(f"{label} must include a timezone")
|
||||
return parsed
|
||||
|
||||
|
||||
def _failed_observation(node: str, error: str) -> NodeObservation:
|
||||
return NodeObservation(node, None, False, False, None, None, None, None, "", False, error)
|
||||
return NodeObservation(
|
||||
node=node,
|
||||
readiness_status=None,
|
||||
release_allowed=False,
|
||||
provider_acceptance_verified=False,
|
||||
receipt_id=None,
|
||||
key_id=None,
|
||||
target_release_id=None,
|
||||
target_image_digest=None,
|
||||
provider_problems="",
|
||||
kill_switch_active=False,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def _read_service_key(path: Path | None) -> str | None:
|
||||
@@ -232,6 +328,7 @@ def _parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--timeout-seconds", type=float, default=5.0)
|
||||
parser.add_argument("--deadline-seconds", type=float, default=300.0)
|
||||
parser.add_argument("--poll-interval-seconds", type=float, default=2.0)
|
||||
parser.add_argument("--max-observation-age-seconds", type=float, default=120.0)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -243,6 +340,8 @@ def main() -> int:
|
||||
raise ValueError("multi-node verification requires at least two distinct nodes")
|
||||
if not 0.1 <= args.timeout_seconds <= 60 or not math.isfinite(args.timeout_seconds):
|
||||
raise ValueError("timeout must be between 0.1 and 60 seconds")
|
||||
if not 1 <= args.max_observation_age_seconds <= 600 or not math.isfinite(args.max_observation_age_seconds):
|
||||
raise ValueError("maximum observation age must be between 1 and 600 seconds")
|
||||
service_key = _read_service_key(args.service_key_file)
|
||||
fetcher = lambda node: fetch_node_readiness(
|
||||
node,
|
||||
@@ -262,6 +361,7 @@ def main() -> int:
|
||||
expected_receipt_id=args.expected_receipt_id,
|
||||
expected_key_id=args.expected_key_id,
|
||||
expected_image_digest=args.expected_image_digest,
|
||||
max_observation_age_seconds=args.max_observation_age_seconds,
|
||||
)
|
||||
else:
|
||||
if not 1 <= args.deadline_seconds <= 900 or not math.isfinite(args.deadline_seconds):
|
||||
@@ -275,6 +375,12 @@ def main() -> int:
|
||||
deadline_seconds=args.deadline_seconds,
|
||||
poll_interval_seconds=args.poll_interval_seconds,
|
||||
)
|
||||
problems.extend(
|
||||
verify_observation_provenance(
|
||||
observations,
|
||||
max_age_seconds=args.max_observation_age_seconds,
|
||||
)
|
||||
)
|
||||
summary = {
|
||||
"mode": args.mode,
|
||||
"status": "passed" if not problems else "failed",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def load_script() -> ModuleType:
|
||||
path = REPO_ROOT / "yuqei-infra/observability/receive-production-alert.py"
|
||||
spec = importlib.util.spec_from_file_location("yuqei_alert_receiver", path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
receiver = load_script()
|
||||
|
||||
|
||||
def _start(tmp_path: Path) -> tuple[object, str, threading.Thread]:
|
||||
token = "a" * 64
|
||||
token_file = tmp_path / "token"
|
||||
token_file.write_text(token + "\n", encoding="utf-8")
|
||||
server = receiver.build_server(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
token_file=token_file,
|
||||
event_dir=tmp_path / "events",
|
||||
max_body_bytes=4096,
|
||||
)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
url = f"http://127.0.0.1:{server.server_address[1]}/ops/alerts/{token}"
|
||||
return server, url, thread
|
||||
|
||||
|
||||
def _payload() -> bytes:
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "warning",
|
||||
"checked_at": "2026-08-12T10:00:00+08:00",
|
||||
"warning_checks": ["disk_root"],
|
||||
"failed_checks": [],
|
||||
"alert_delivery": {"attempted": True, "delivered": False},
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def test_receiver_persists_event_and_returns_event_id(tmp_path: Path) -> None:
|
||||
server, url, thread = _start(tmp_path)
|
||||
try:
|
||||
request = Request(url, data=_payload(), method="POST", headers={"Content-Type": "application/json"})
|
||||
with urlopen(request, timeout=5) as response:
|
||||
result = json.loads(response.read())
|
||||
assert response.status == 201
|
||||
event_path = tmp_path / "events" / f"{result['event_id']}.json"
|
||||
receipt = json.loads(event_path.read_text(encoding="utf-8"))
|
||||
assert receipt["event_id"] == result["event_id"]
|
||||
assert receipt["payload"]["warning_checks"] == ["disk_root"]
|
||||
assert len(receipt["body_sha256"]) == 64
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path_suffix", "content_type", "body", "expected_status"),
|
||||
[
|
||||
("wrong-token", "application/json", _payload(), 404),
|
||||
("a" * 64, "text/plain", _payload(), 415),
|
||||
("a" * 64, "application/json", b'{"status":"pass","checked_at":"x"}', 400),
|
||||
],
|
||||
)
|
||||
def test_receiver_rejects_unauthenticated_or_invalid_alerts(
|
||||
tmp_path: Path,
|
||||
path_suffix: str,
|
||||
content_type: str,
|
||||
body: bytes,
|
||||
expected_status: int,
|
||||
) -> None:
|
||||
server, url, thread = _start(tmp_path)
|
||||
try:
|
||||
base = url.rsplit("/", 1)[0]
|
||||
request = Request(
|
||||
f"{base}/{path_suffix}",
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={"Content-Type": content_type},
|
||||
)
|
||||
with pytest.raises(HTTPError) as error:
|
||||
urlopen(request, timeout=5)
|
||||
assert error.value.code == expected_status
|
||||
event_dir = tmp_path / "events"
|
||||
assert not event_dir.exists() or not list(event_dir.glob("*.json"))
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_nginx_location_is_post_only_and_loopback_proxy() -> None:
|
||||
snippet = (
|
||||
REPO_ROOT / "yuqei-infra/observability/nginx-production-alert-location.conf"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "location ^~ /ops/alerts/" in snippet
|
||||
assert "proxy_pass http://127.0.0.1:19091;" in snippet
|
||||
assert "client_max_body_size 1m;" in snippet
|
||||
assert "limit_except POST" in snippet
|
||||
assert "deny all;" in snippet
|
||||
@@ -169,6 +169,8 @@ def _main_args(tmp_path: Path) -> SimpleNamespace:
|
||||
backup_require_separate_mount=True,
|
||||
timeout_seconds=1,
|
||||
webhook_url_file=tmp_path / "webhook-url",
|
||||
alert_state_file=None,
|
||||
alert_resend_seconds=3600,
|
||||
artifact_dir=tmp_path / "artifact",
|
||||
)
|
||||
|
||||
@@ -234,9 +236,38 @@ def test_warning_dispatches_webhook_and_records_acceptance_artifact(
|
||||
"attempted": True,
|
||||
"delivered": True,
|
||||
"error": None,
|
||||
"receiver_event_id": None,
|
||||
"receiver_received_at": None,
|
||||
"receiver_body_sha256": None,
|
||||
"suppressed": False,
|
||||
"decision": "deduplication_not_configured",
|
||||
}
|
||||
|
||||
|
||||
def test_webhook_receipt_is_recorded_in_local_acceptance_artifact(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
args = _main_args(tmp_path)
|
||||
monkeypatch.setattr(health, "_parse_args", lambda: args)
|
||||
_mock_non_capacity_checks(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
health,
|
||||
"send_webhook",
|
||||
lambda *_args, **_kwargs: {
|
||||
"event_id": "yuqei-alert-0123456789abcdef",
|
||||
"received_at": "2026-08-12T02:00:00+00:00",
|
||||
"body_sha256": "a" * 64,
|
||||
},
|
||||
)
|
||||
|
||||
assert health.main() == 0
|
||||
summary = json.loads((args.artifact_dir / "production-health-summary.json").read_text(encoding="utf-8"))
|
||||
assert summary["alert_delivery"]["receiver_event_id"] == "yuqei-alert-0123456789abcdef"
|
||||
assert summary["alert_delivery"]["receiver_received_at"] == "2026-08-12T02:00:00+00:00"
|
||||
assert summary["alert_delivery"]["receiver_body_sha256"] == "a" * 64
|
||||
|
||||
|
||||
def test_webhook_delivery_failure_is_visible_to_systemd(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
@@ -259,6 +290,102 @@ def test_webhook_delivery_failure_is_visible_to_systemd(
|
||||
assert summary["alert_delivery"]["error"] == "TimeoutError"
|
||||
|
||||
|
||||
def test_duplicate_alert_is_suppressed_until_resend_window(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
args = _main_args(tmp_path)
|
||||
args.alert_state_file = tmp_path / "alert-state.json"
|
||||
monkeypatch.setattr(health, "_parse_args", lambda: args)
|
||||
_mock_non_capacity_checks(monkeypatch)
|
||||
delivered: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
health,
|
||||
"send_webhook",
|
||||
lambda _path, summary, **_kwargs: delivered.append(summary["status"]) or {},
|
||||
)
|
||||
|
||||
assert health.main() == 0
|
||||
assert health.main() == 0
|
||||
summary = json.loads((args.artifact_dir / "production-health-summary.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert delivered == ["warning"]
|
||||
assert summary["alert_delivery"]["attempted"] is False
|
||||
assert summary["alert_delivery"]["suppressed"] is True
|
||||
assert summary["alert_delivery"]["decision"] == "duplicate_within_resend_window"
|
||||
|
||||
|
||||
def test_alert_state_change_bypasses_deduplication(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
args = _main_args(tmp_path)
|
||||
args.alert_state_file = tmp_path / "alert-state.json"
|
||||
monkeypatch.setattr(health, "_parse_args", lambda: args)
|
||||
_mock_non_capacity_checks(monkeypatch)
|
||||
delivered: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
health,
|
||||
"send_webhook",
|
||||
lambda _path, summary, **_kwargs: delivered.append(summary["status"]) or {},
|
||||
)
|
||||
|
||||
assert health.main() == 0
|
||||
monkeypatch.setattr(
|
||||
health,
|
||||
"check_disk",
|
||||
lambda **policy: health.HealthCheck(
|
||||
policy["key"],
|
||||
"fail" if policy["key"] == "disk_root" else "pass",
|
||||
"changed",
|
||||
),
|
||||
)
|
||||
assert health.main() == 1
|
||||
|
||||
assert delivered == ["warning", "fail"]
|
||||
|
||||
|
||||
def test_healthy_run_resets_alert_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
args = _main_args(tmp_path)
|
||||
args.alert_state_file = tmp_path / "alert-state.json"
|
||||
monkeypatch.setattr(health, "_parse_args", lambda: args)
|
||||
_mock_non_capacity_checks(monkeypatch)
|
||||
monkeypatch.setattr(health, "send_webhook", lambda *_args, **_kwargs: {})
|
||||
assert health.main() == 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
health,
|
||||
"check_disk",
|
||||
lambda **policy: health.HealthCheck(policy["key"], "pass", "healthy"),
|
||||
)
|
||||
assert health.main() == 0
|
||||
|
||||
state = json.loads(args.alert_state_file.read_text(encoding="utf-8"))
|
||||
assert state["fingerprint"] is None
|
||||
assert state["delivered_at"] is None
|
||||
|
||||
|
||||
def test_alert_state_write_failure_is_recorded_and_exits_two(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
args = _main_args(tmp_path)
|
||||
args.alert_state_file = tmp_path / "alert-state.json"
|
||||
monkeypatch.setattr(health, "_parse_args", lambda: args)
|
||||
_mock_non_capacity_checks(monkeypatch)
|
||||
monkeypatch.setattr(health, "send_webhook", lambda *_args, **_kwargs: {})
|
||||
monkeypatch.setattr(
|
||||
health,
|
||||
"write_alert_state",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("read-only filesystem")),
|
||||
)
|
||||
|
||||
assert health.main() == 2
|
||||
summary = json.loads((args.artifact_dir / "production-health-summary.json").read_text(encoding="utf-8"))
|
||||
assert summary["alert_delivery"]["delivered"] is True
|
||||
assert summary["alert_delivery"]["error"] == "StateWriteError"
|
||||
|
||||
|
||||
def test_failed_capacity_check_dispatches_webhook_and_exits_one(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
@@ -348,4 +475,8 @@ def test_systemd_timer_and_service_cover_all_capacity_paths() -> None:
|
||||
assert "--home-disk-path /home" in unit
|
||||
assert "--home-require-separate-mount" in unit
|
||||
assert "--backup-disk-path ${BACKUP_HOST_DIR}" in unit
|
||||
assert "--backup-disk-critical-free-gib ${BACKUP_DISK_CRITICAL_FREE_GIB}" in unit
|
||||
assert "--backup-require-separate-mount" in unit
|
||||
assert "--webhook-url-file ${PRODUCTION_ALERT_WEBHOOK_URL_FILE}" in unit
|
||||
assert "--alert-state-file /opt/apps/ai-contract-platform-v2-production/project-docs/qa-artifacts/v2-production-health/alert-state.json" in unit
|
||||
assert "--alert-resend-seconds 3600" in unit
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
@@ -193,3 +194,333 @@ def test_sign_rejects_a_different_private_key(tmp_path: Path) -> None:
|
||||
password_file=other_password,
|
||||
confirmation=policy.SIGN_CONFIRMATION,
|
||||
)
|
||||
|
||||
|
||||
def test_trust_request_binds_signed_approval_without_granting_trust(tmp_path: Path) -> None:
|
||||
_package, pending, keyring, private, _public, password = prepare(tmp_path)
|
||||
final_payload = policy.finalize_payload(
|
||||
pending,
|
||||
approved_at="2026-08-12T13:00:00+08:00",
|
||||
expires_at="2026-08-19T13:00:00+08:00",
|
||||
)
|
||||
approval = policy.sign_payload(
|
||||
final_payload,
|
||||
private_key_file=private,
|
||||
password_file=password,
|
||||
confirmation=policy.SIGN_CONFIRMATION,
|
||||
)
|
||||
|
||||
trust_request = policy.build_trust_request(
|
||||
approval,
|
||||
candidate_keyring=keyring,
|
||||
policy_dir=POLICY_SOURCE,
|
||||
requested_at="2026-08-12T14:00:00+08:00",
|
||||
)
|
||||
|
||||
assert trust_request["schema_version"] == policy.TRUST_REQUEST_SCHEMA
|
||||
assert trust_request["status"] == "pending_independent_trust_approval"
|
||||
assert trust_request["activation_authorized"] is False
|
||||
assert trust_request["approval_id"] == approval["approval_id"]
|
||||
assert trust_request["approval_sha256"] == policy.sha256_bytes(policy.canonical_json(approval))
|
||||
assert trust_request["candidate_keyring_sha256"] == policy.sha256_bytes(
|
||||
policy.canonical_json(keyring)
|
||||
)
|
||||
assert "mark_candidate_keyring_trusted" in trust_request["prohibited_automatic_actions"]
|
||||
assert "publish_policy_documents" in trust_request["prohibited_automatic_actions"]
|
||||
|
||||
|
||||
def test_trust_request_rejects_expired_approval_or_non_candidate_keyring(tmp_path: Path) -> None:
|
||||
_package, pending, keyring, private, _public, password = prepare(tmp_path)
|
||||
final_payload = policy.finalize_payload(
|
||||
pending,
|
||||
approved_at="2026-08-12T13:00:00+08:00",
|
||||
expires_at="2026-08-19T13:00:00+08:00",
|
||||
)
|
||||
approval = policy.sign_payload(
|
||||
final_payload,
|
||||
private_key_file=private,
|
||||
password_file=password,
|
||||
confirmation=policy.SIGN_CONFIRMATION,
|
||||
)
|
||||
|
||||
with pytest.raises(policy.PolicyApprovalError, match="not valid at checked_at"):
|
||||
policy.build_trust_request(
|
||||
approval,
|
||||
candidate_keyring=keyring,
|
||||
policy_dir=POLICY_SOURCE,
|
||||
requested_at="2026-08-19T13:00:01+08:00",
|
||||
)
|
||||
|
||||
trusted = {**keyring, "status": "trusted"}
|
||||
with pytest.raises(policy.PolicyApprovalError, match="signed trust basis"):
|
||||
policy.verify_approval(
|
||||
approval,
|
||||
keyring=trusted,
|
||||
policy_dir=POLICY_SOURCE,
|
||||
checked_at="2026-08-12T14:00:00+08:00",
|
||||
allow_candidate_keyring=False,
|
||||
)
|
||||
with pytest.raises(policy.PolicyApprovalError, match="candidate_not_trusted"):
|
||||
policy.build_trust_request(
|
||||
approval,
|
||||
candidate_keyring=trusted,
|
||||
policy_dir=POLICY_SOURCE,
|
||||
requested_at="2026-08-12T14:00:00+08:00",
|
||||
)
|
||||
|
||||
|
||||
def make_signed_trust_chain(tmp_path: Path) -> tuple[dict, dict, dict, dict, Path]:
|
||||
_package, pending, keyring, legal_private, _legal_public, legal_password = prepare(tmp_path)
|
||||
legal_payload = policy.finalize_payload(
|
||||
pending,
|
||||
approved_at="2026-08-12T13:00:00+08:00",
|
||||
expires_at="2026-08-19T13:00:00+08:00",
|
||||
)
|
||||
legal_approval = policy.sign_payload(
|
||||
legal_payload,
|
||||
private_key_file=legal_private,
|
||||
password_file=legal_password,
|
||||
confirmation=policy.SIGN_CONFIRMATION,
|
||||
)
|
||||
trust_request = policy.build_trust_request(
|
||||
legal_approval,
|
||||
candidate_keyring=keyring,
|
||||
policy_dir=POLICY_SOURCE,
|
||||
requested_at="2026-08-12T14:00:00+08:00",
|
||||
)
|
||||
trust_private, trust_public, trust_password = make_key_files(tmp_path, stem="trust-approver")
|
||||
trust_payload = policy.build_trust_approval_payload(
|
||||
trust_request,
|
||||
approval=legal_approval,
|
||||
candidate_keyring=keyring,
|
||||
policy_dir=POLICY_SOURCE,
|
||||
trust_approver_public_key_file=trust_public,
|
||||
key_id="policy-trust-ops-2026q3",
|
||||
approver_id="security@example.test",
|
||||
reviewer="Named Security Reviewer",
|
||||
role="Security Officer",
|
||||
organization="Independent Security Operations",
|
||||
responsibility="security",
|
||||
approved_at="2026-08-12T15:00:00+08:00",
|
||||
expires_at="2026-08-19T12:00:00+08:00",
|
||||
key_not_before="2026-08-01T00:00:00+08:00",
|
||||
key_not_after="2026-09-30T23:59:59+08:00",
|
||||
)
|
||||
trust_approval = policy.sign_trust_approval_payload(
|
||||
trust_payload,
|
||||
private_key_file=trust_private,
|
||||
password_file=trust_password,
|
||||
confirmation=policy.TRUST_SIGN_CONFIRMATION,
|
||||
)
|
||||
return legal_approval, keyring, trust_request, trust_approval, trust_public
|
||||
|
||||
|
||||
def test_independent_signed_trust_approval_is_required_to_issue_trusted_keyring(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
legal_approval, keyring, trust_request, trust_approval, trust_public = make_signed_trust_chain(
|
||||
tmp_path
|
||||
)
|
||||
|
||||
trusted = policy.issue_trusted_keyring(
|
||||
trust_approval,
|
||||
trust_request=trust_request,
|
||||
approval=legal_approval,
|
||||
candidate_keyring=keyring,
|
||||
policy_dir=POLICY_SOURCE,
|
||||
trust_approver_public_key_file=trust_public,
|
||||
checked_at="2026-08-12T16:00:00+08:00",
|
||||
)
|
||||
|
||||
assert trusted["status"] == "trusted"
|
||||
assert trusted["keys"] == keyring["keys"]
|
||||
assert trusted["trust_basis"]["policy_activation_authorized"] is False
|
||||
assert trusted["trust_basis"]["trust_request_sha256"] == policy.sha256_bytes(
|
||||
policy.canonical_json(trust_request)
|
||||
)
|
||||
verified = policy.verify_approval(
|
||||
legal_approval,
|
||||
keyring=trusted,
|
||||
policy_dir=POLICY_SOURCE,
|
||||
checked_at="2026-08-12T16:00:00+08:00",
|
||||
allow_candidate_keyring=False,
|
||||
)
|
||||
assert verified["status"] == "verified"
|
||||
|
||||
tampered = json.loads(json.dumps(trusted))
|
||||
signature = tampered["trust_basis"]["trust_approval"]["signature"]
|
||||
tampered["trust_basis"]["trust_approval"]["signature"] = "A" + signature[1:]
|
||||
with pytest.raises(policy.PolicyApprovalError, match="trust approval hash"):
|
||||
policy.verify_approval(
|
||||
legal_approval,
|
||||
keyring=tampered,
|
||||
policy_dir=POLICY_SOURCE,
|
||||
checked_at="2026-08-12T16:00:00+08:00",
|
||||
allow_candidate_keyring=False,
|
||||
)
|
||||
|
||||
incomplete = json.loads(json.dumps(trusted))
|
||||
incomplete["trust_basis"]["trust_approval"]["payload"]["decision"]["attestations"] = []
|
||||
incomplete["trust_basis"]["trust_approval_sha256"] = policy.sha256_bytes(
|
||||
policy.canonical_json(incomplete["trust_basis"]["trust_approval"])
|
||||
)
|
||||
with pytest.raises(policy.PolicyApprovalError, match="attestations are incomplete"):
|
||||
policy.verify_approval(
|
||||
legal_approval,
|
||||
keyring=incomplete,
|
||||
policy_dir=POLICY_SOURCE,
|
||||
checked_at="2026-08-12T16:00:00+08:00",
|
||||
allow_candidate_keyring=False,
|
||||
)
|
||||
|
||||
different_approval = json.loads(json.dumps(legal_approval))
|
||||
different_approval["approval_id"] = "policy-approval-different-material"
|
||||
with pytest.raises(policy.PolicyApprovalError, match="exact policy approval"):
|
||||
policy.verify_trusted_keyring_basis(trusted, approval=different_approval)
|
||||
|
||||
|
||||
def test_trust_approval_rejects_same_approver_or_same_key(tmp_path: Path) -> None:
|
||||
_package, pending, keyring, legal_private, legal_public, legal_password = prepare(tmp_path)
|
||||
legal_payload = policy.finalize_payload(
|
||||
pending,
|
||||
approved_at="2026-08-12T13:00:00+08:00",
|
||||
expires_at="2026-08-19T13:00:00+08:00",
|
||||
)
|
||||
legal_approval = policy.sign_payload(
|
||||
legal_payload,
|
||||
private_key_file=legal_private,
|
||||
password_file=legal_password,
|
||||
confirmation=policy.SIGN_CONFIRMATION,
|
||||
)
|
||||
trust_request = policy.build_trust_request(
|
||||
legal_approval,
|
||||
candidate_keyring=keyring,
|
||||
policy_dir=POLICY_SOURCE,
|
||||
requested_at="2026-08-12T14:00:00+08:00",
|
||||
)
|
||||
|
||||
common = dict(
|
||||
approval=legal_approval,
|
||||
candidate_keyring=keyring,
|
||||
policy_dir=POLICY_SOURCE,
|
||||
key_id="policy-trust-ops-2026q3",
|
||||
reviewer="Named Reviewer",
|
||||
role="Security Officer",
|
||||
organization="Independent Security Operations",
|
||||
responsibility="security",
|
||||
approved_at="2026-08-12T15:00:00+08:00",
|
||||
expires_at="2026-08-19T12:00:00+08:00",
|
||||
key_not_before="2026-08-01T00:00:00+08:00",
|
||||
key_not_after="2026-09-30T23:59:59+08:00",
|
||||
)
|
||||
_other_private, other_public, _other_password = make_key_files(tmp_path, stem="independent")
|
||||
with pytest.raises(policy.PolicyApprovalError, match="approver must differ"):
|
||||
policy.build_trust_approval_payload(
|
||||
trust_request,
|
||||
trust_approver_public_key_file=other_public,
|
||||
approver_id="cyuqei@gmail.com",
|
||||
**common,
|
||||
)
|
||||
with pytest.raises(policy.PolicyApprovalError, match="key must differ"):
|
||||
policy.build_trust_approval_payload(
|
||||
trust_request,
|
||||
trust_approver_public_key_file=legal_public,
|
||||
approver_id="security@example.test",
|
||||
**common,
|
||||
)
|
||||
|
||||
|
||||
def test_issue_trusted_keyring_rejects_tampered_request_and_signature(tmp_path: Path) -> None:
|
||||
legal_approval, keyring, trust_request, trust_approval, trust_public = make_signed_trust_chain(
|
||||
tmp_path
|
||||
)
|
||||
tampered_request = json.loads(json.dumps(trust_request))
|
||||
tampered_request["policy_version"] = "tampered"
|
||||
with pytest.raises(policy.PolicyApprovalError, match="source material"):
|
||||
policy.issue_trusted_keyring(
|
||||
trust_approval,
|
||||
trust_request=tampered_request,
|
||||
approval=legal_approval,
|
||||
candidate_keyring=keyring,
|
||||
policy_dir=POLICY_SOURCE,
|
||||
trust_approver_public_key_file=trust_public,
|
||||
checked_at="2026-08-12T16:00:00+08:00",
|
||||
)
|
||||
|
||||
|
||||
def test_trust_approval_requires_signed_independent_review_attestations(tmp_path: Path) -> None:
|
||||
legal_approval, keyring, trust_request, trust_approval, trust_public = make_signed_trust_chain(
|
||||
tmp_path
|
||||
)
|
||||
payload = trust_approval["payload"]
|
||||
assert payload["decision"]["responsibility"] == "security"
|
||||
assert payload["decision"]["attestations"] == policy.TRUST_REVIEW_ATTESTATIONS
|
||||
|
||||
tampered = json.loads(json.dumps(trust_approval))
|
||||
tampered["payload"]["decision"]["attestations"] = []
|
||||
with pytest.raises(policy.PolicyApprovalError, match="attestations are incomplete"):
|
||||
policy.issue_trusted_keyring(
|
||||
tampered,
|
||||
trust_request=trust_request,
|
||||
approval=legal_approval,
|
||||
candidate_keyring=keyring,
|
||||
policy_dir=POLICY_SOURCE,
|
||||
trust_approver_public_key_file=trust_public,
|
||||
checked_at="2026-08-12T16:00:00+08:00",
|
||||
)
|
||||
|
||||
tampered_approval = json.loads(json.dumps(trust_approval))
|
||||
tampered_approval["signature"] = "A" + tampered_approval["signature"][1:]
|
||||
with pytest.raises(policy.PolicyApprovalError, match="signature verification failed"):
|
||||
policy.issue_trusted_keyring(
|
||||
tampered_approval,
|
||||
trust_request=trust_request,
|
||||
approval=legal_approval,
|
||||
candidate_keyring=keyring,
|
||||
policy_dir=POLICY_SOURCE,
|
||||
trust_approver_public_key_file=trust_public,
|
||||
checked_at="2026-08-12T16:00:00+08:00",
|
||||
)
|
||||
|
||||
|
||||
def test_issue_reconstructs_full_trust_payload_before_verifying_signature(tmp_path: Path) -> None:
|
||||
legal_approval, keyring, trust_request, trust_approval, trust_public = make_signed_trust_chain(
|
||||
tmp_path
|
||||
)
|
||||
payload = json.loads(json.dumps(trust_approval["payload"]))
|
||||
payload["legal_approver"]["approver_id"] = "different-unbound-id@example.test"
|
||||
trust_private = Ed25519PrivateKey.generate()
|
||||
trust_public.write_bytes(
|
||||
trust_private.public_key().public_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
)
|
||||
payload["trust_approver"]["public_key_file_sha256"] = policy.sha256_bytes(
|
||||
trust_public.read_bytes()
|
||||
)
|
||||
payload["trust_approver"]["public_key_raw_sha256"] = policy.sha256_bytes(
|
||||
trust_private.public_key().public_bytes(
|
||||
serialization.Encoding.Raw,
|
||||
serialization.PublicFormat.Raw,
|
||||
)
|
||||
)
|
||||
payload_raw = policy.canonical_json(payload)
|
||||
signature = trust_private.sign(policy.TRUST_SIGNATURE_DOMAIN + payload_raw)
|
||||
forged = {
|
||||
"schema_version": policy.TRUST_APPROVAL_SCHEMA,
|
||||
"trust_approval_id": f"policy-key-trust-approval-{policy.sha256_bytes(payload_raw + signature)[:24]}",
|
||||
"payload": payload,
|
||||
"signature": base64.b64encode(signature).decode("ascii"),
|
||||
}
|
||||
|
||||
with pytest.raises(policy.PolicyApprovalError, match="source material"):
|
||||
policy.issue_trusted_keyring(
|
||||
forged,
|
||||
trust_request=trust_request,
|
||||
approval=legal_approval,
|
||||
candidate_keyring=keyring,
|
||||
policy_dir=POLICY_SOURCE,
|
||||
trust_approver_public_key_file=trust_public,
|
||||
checked_at="2026-08-12T16:00:00+08:00",
|
||||
)
|
||||
|
||||
@@ -82,6 +82,7 @@ def production_values() -> dict[str, str]:
|
||||
"AI_PLATFORM_PROVIDER_ACCEPTANCE_EXPECTED_RECEIPT_SHA256": hashlib.sha256(
|
||||
b"pinned-production-provider-acceptance-receipt"
|
||||
).hexdigest(),
|
||||
"PRODUCTION_ALERT_WEBHOOK_URL_FILE": "/etc/yuqei/monitoring/alert-webhook-url",
|
||||
"AI_PLATFORM_ANTI_ABUSE_HASH_SECRET": "ai-anti-abuse-secret-that-is-long-and-unique-006a", # pragma: allowlist secret
|
||||
"CONTRACT_API_DATABASE_URL": "postgresql+psycopg://yuqei_prod:encoded-password@postgres:5432/yuqei_contract",
|
||||
"AI_PLATFORM_DATABASE_URL": "postgresql+psycopg://yuqei_prod:encoded-password@postgres:5432/yuqei_ai",
|
||||
@@ -316,6 +317,32 @@ def test_provider_acceptance_promotion_files_are_present_and_hash_pinned(tmp_pat
|
||||
assert tampered["provider_acceptance_receipt_hash"].status == "fail"
|
||||
|
||||
|
||||
def test_production_preflight_requires_owner_only_https_alert_webhook(tmp_path: Path) -> None:
|
||||
values = production_values()
|
||||
webhook = tmp_path / "alert-webhook-url"
|
||||
webhook.write_text("https://alerts.acme.cn/hooks/yuqei\n", encoding="utf-8")
|
||||
webhook.chmod(0o600)
|
||||
values["PRODUCTION_ALERT_WEBHOOK_URL_FILE"] = str(webhook.resolve())
|
||||
|
||||
checks = {check.key: check for check in preflight.validate_files(values)}
|
||||
|
||||
assert checks["alert_webhook_file"].status == "pass"
|
||||
if "alert_webhook_permissions" in checks:
|
||||
assert checks["alert_webhook_permissions"].status == "pass"
|
||||
|
||||
webhook.write_text("http://alerts.acme.cn/hooks/yuqei\n", encoding="utf-8")
|
||||
assert {check.key: check for check in preflight.validate_files(values)}["alert_webhook_file"].status == "fail"
|
||||
|
||||
|
||||
def test_production_preflight_rejects_missing_alert_webhook_file(tmp_path: Path) -> None:
|
||||
values = production_values()
|
||||
values["PRODUCTION_ALERT_WEBHOOK_URL_FILE"] = str((tmp_path / "missing").resolve())
|
||||
|
||||
checks = {check.key: check for check in preflight.validate_files(values)}
|
||||
|
||||
assert checks["alert_webhook_file"].status == "fail"
|
||||
|
||||
|
||||
def test_production_compose_mounts_only_promotion_verification_material_read_only() -> None:
|
||||
override = (
|
||||
REPO_ROOT / "yuqei-infra/compose/docker-compose.production.yml"
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -30,6 +31,10 @@ def _observation(
|
||||
release_id: str = "release-1",
|
||||
problems: str = "",
|
||||
kill_switch: bool = False,
|
||||
environment: str = "production",
|
||||
generated_at: str | None = None,
|
||||
verification_mode: str = "offline_read_only",
|
||||
diagnostics_consistent: bool = True,
|
||||
):
|
||||
return controls.NodeObservation(
|
||||
node=node,
|
||||
@@ -42,6 +47,10 @@ def _observation(
|
||||
target_image_digest="sha256:" + "2" * 64,
|
||||
provider_problems=problems,
|
||||
kill_switch_active=kill_switch,
|
||||
environment=environment,
|
||||
generated_at=generated_at or datetime.now(UTC).isoformat(),
|
||||
verification_mode=verification_mode,
|
||||
promotion_diagnostics_consistent=diagnostics_consistent,
|
||||
)
|
||||
|
||||
|
||||
@@ -68,6 +77,76 @@ def test_canary_requires_one_fingerprint_across_all_nodes() -> None:
|
||||
assert "nodes do not report one immutable promotion fingerprint" in problems
|
||||
|
||||
|
||||
def test_canary_requires_fresh_production_offline_read_only_diagnostics() -> None:
|
||||
now = datetime(2026, 8, 12, 1, 0, tzinfo=UTC)
|
||||
observations = [
|
||||
_observation(
|
||||
"https://node-a.example",
|
||||
environment="staging",
|
||||
generated_at=now.isoformat(),
|
||||
verification_mode="repository",
|
||||
),
|
||||
_observation(
|
||||
"https://node-b.example",
|
||||
generated_at=(now - timedelta(minutes=5)).isoformat(),
|
||||
diagnostics_consistent=False,
|
||||
),
|
||||
]
|
||||
|
||||
problems = controls.verify_canary(
|
||||
observations,
|
||||
expected_release_id="release-1",
|
||||
expected_receipt_id="provider-receipt-1234567890abcdef12345678",
|
||||
expected_key_id="receipt-key-1",
|
||||
expected_image_digest="sha256:" + "2" * 64,
|
||||
max_observation_age_seconds=120,
|
||||
now=now,
|
||||
)
|
||||
|
||||
assert any("not from production" in item for item in problems)
|
||||
assert any("not offline read-only" in item for item in problems)
|
||||
assert any("diagnostics are inconsistent" in item for item in problems)
|
||||
assert any("response is stale" in item for item in problems)
|
||||
|
||||
|
||||
def test_parse_readiness_observation_cross_checks_diagnostics() -> None:
|
||||
payload = {
|
||||
"environment": "production",
|
||||
"status": "ready",
|
||||
"release_allowed": True,
|
||||
"provider_acceptance_verified": True,
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
"checks": [
|
||||
{
|
||||
"code": "provider_acceptance",
|
||||
"details": {
|
||||
"receipt_id": "provider-receipt-1234567890abcdef12345678",
|
||||
"key_id": "receipt-key-1",
|
||||
"target_release_id": "release-1",
|
||||
"target_image_digest": "sha256:" + "2" * 64,
|
||||
"problems": "",
|
||||
},
|
||||
},
|
||||
{"code": "provider_promotion_kill_switch", "details": {"active": False}},
|
||||
],
|
||||
"promotion_diagnostics": {
|
||||
"verification_mode": "offline_read_only",
|
||||
"verified": True,
|
||||
"kill_switch_active": False,
|
||||
"release_id": "release-1",
|
||||
"image_digest": "sha256:" + "2" * 64,
|
||||
"receipt_id": "provider-receipt-1234567890abcdef12345678",
|
||||
"key_id": "receipt-key-1",
|
||||
},
|
||||
}
|
||||
|
||||
observation = controls.parse_readiness_observation("https://node-a.example", payload)
|
||||
assert observation.promotion_diagnostics_consistent is True
|
||||
payload["promotion_diagnostics"]["receipt_id"] = "provider-receipt-abcdefabcdefabcdefabcdef"
|
||||
observation = controls.parse_readiness_observation("https://node-a.example", payload)
|
||||
assert observation.promotion_diagnostics_consistent is False
|
||||
|
||||
|
||||
def test_revocation_waits_for_every_node_and_honors_sla() -> None:
|
||||
now = [0.0]
|
||||
calls = {"https://node-a.example": 0, "https://node-b.example": 0}
|
||||
@@ -152,6 +231,7 @@ def test_control_workflow_is_protected_read_only_and_cleans_credentials() -> Non
|
||||
assert "environment: production-provider-promotion-control" in workflow
|
||||
assert "permissions:\n contents: read" in workflow
|
||||
assert "verify-provider-promotion-nodes.py" in workflow
|
||||
assert "--max-observation-age-seconds" in workflow
|
||||
assert "- canary" in workflow and "- revocation" in workflow and "- kill-switch" in workflow
|
||||
assert "${{ inputs.node_a }}" not in workflow.split("run: |", 1)[1]
|
||||
assert "${{ inputs.node_b }}" not in workflow.split("run: |", 1)[1]
|
||||
|
||||
Reference in New Issue
Block a user