diff --git a/project-docs/V2-privileged-identity-operations-governance-2026-08-02.md b/project-docs/V2-privileged-identity-operations-governance-2026-08-02.md index 8f893b94..095c2ca0 100644 --- a/project-docs/V2-privileged-identity-operations-governance-2026-08-02.md +++ b/project-docs/V2-privileged-identity-operations-governance-2026-08-02.md @@ -783,3 +783,262 @@ Contract API 单进程全量回归在 10 分钟命令上限处被终止且未返 本地运行 SQLite 库升级前为 0040,outbox 行数、跨操作事件错配和非法租约计数均为 `0`;停止 Contract API 后已生成 `contract-api-20260802-before-0041-20260803-012502.sqlite3` 备份,再升级到 0041。补充 SQLite 触发器后执行 0041 downgrade/upgrade 循环,确认复合索引、复合外键、租约 CHECK 和两条关联拒绝触发器均存在;Contract API 重启后 `8110` 健康检查返回 `200`,Web `3021` 与 AI Platform `8111` 未重启。 本阶段没有新增或改变任何 API、DTO、Web 页面或 worker transport,不增加人工 retry/requeue/ack,不启用 SMTP/工单/Webhook,不开放 execute、rollback、merge、split、recover、grant、revoke、support access 或其他身份执行入口。审批完成仍只表示治理审批状态,不代表身份数据已变更。 + +## 24. 外发安全契约与消费者幂等检查点(2026-08-03) + +本检查点冻结真实 transport 上线前的 provider-neutral 契约。现有数据库 outbox payload 继续作为内部事务记录,但 transport 不再接触 `ClaimedPrivilegedIdentityMessage` 或内部 payload;发送边界只允许接收经过 allowlist 重建、摘要和签名的安全 envelope。 + +### 24.1 Egress envelope + +固定 contract version 为 `privileged-identity-egress.v1`,字段仅包括: + +- contract version、key ID; +- HMAC 派生的外部 delivery key; +- effect type、event type、operation type; +- status before/after; +- occurred at; +- body digest 和 signature。 + +不得外发 message/event/operation ID、内部 idempotency key、trace ID、sequence number、目标引用或指纹、snapshot/hash、actor、tenant/user/account ID、reason、ticket、evidence、审批评论、邮箱、凭据或原始错误。实现从内部 payload 逐字段 allowlist 重建 envelope,不得通过删字段方式转发原对象。 + +外部 delivery key 使用不少于 32 bytes 的独立、稳定幂等密钥和专用 HMAC domain 从内部幂等键派生;body 使用排序、无空白的 ASCII JSON 规范化并计算 SHA-256 digest;signature 使用另一把不少于 32 bytes、带 key ID 的可轮换签名密钥。签名轮换不得改变同一消息的 delivery key,避免待重试消息被消费者视为新副作用。缺失/短密钥和非法 key ID 在 claim 前失败,日志不得输出两类密钥、签名或 envelope 原文。 + +### 24.2 Receipt 与消费者幂等 + +transport 必须返回类型化 receipt:`accepted`、`duplicate`、`retryable_failure` 或 `permanent_failure`。发送方同时核对 delivery key 和 body digest;只有匹配的 accepted/duplicate 可标记 delivered。retryable 使用既有指数退避;permanent、未知 receipt、key/digest 不匹配直接 dead-letter,数据库只记录稳定错误码,不记录响应正文。 + +参考消费者以 `(consumer_id, delivery_key)` 为唯一 receipt: + +- 首次合法 envelope 在业务副作用成功后保存 digest 并返回 accepted; +- 同 key/同 digest 返回 duplicate,不重复副作用; +- 同 key/不同 digest 返回永久 `idempotency_conflict`; +- 未知 schema major、签名失败、载荷超限永久拒绝; +- 超时、限流或临时依赖不可用归类 retryable。 + +内存参考实现仅用于契约测试。生产消费者必须在同一个持久化事务中提交 receipt 与业务副作用,才能覆盖“副作用成功但 ACK 丢失”后的安全重投;不得把内存去重或进程内锁解释为生产 exactly-once。 + +### 24.3 Worker 边界和验收证据 + +- worker 已移除 `run_once(deliver=...)` 测试回调旁路;启用时必须显式注入 transport 与 delivery contract。 +- 默认 disabled transport 继续返回 0 且不 claim;enabled transport 缺 contract 在 claim 前失败。 +- payload hash 在构建 envelope 前继续校验;契约字段非法永久 dead-letter。 +- accepted/duplicate 确认 delivered;retryable 进入 retry_scheduled;permanent 或 receipt mismatch 进入 dead-letter。 +- 新增契约/worker 定向测试 `8/8`,覆盖强密钥、字段红线、固定生命周期值、带时区时间、稳定 HMAC/signature、key 轮换、重复确认、幂等冲突、ACK 丢失、签名/schema 拒绝、claim 前配置失败、retry 恢复和 receipt mismatch。 +- 特权身份操作 `20/20`、SQLite 0041 迁移 `2/2` 通过。真实 PostgreSQL 隔离 schema `1/1` 通过,确认并发 claim、租约恢复前后 envelope 完全一致、内部关联值不外发、重试/dead-letter、0041 完整性和测试 schema 清理。 + +本阶段未新增迁移、API、Web 或运行入口,未连接真实 transport。SMTP、工单和通用 Webhook 继续禁止;后续 provider 必须具备固定目标 allowlist、TLS/服务认证、durable ACK、消费者持久化 inbox、重试/背压、DLQ 保留、访问审计和密钥轮换。身份执行器、人工 outbox retry、support access 和所有破坏性身份写入口继续关闭。 + +## 25. 统一执行器只读 preflight 与固定锁计划检查点(2026-08-03) + +本检查点只建设未来统一执行器的验证契约,不建设或开放执行器。新增平台管理员只读 `GET /api/v1/identity/privileged-operations/{operation_id}/execution-preflight`;没有新增 execute、rollback、merge、split、recover、grant、revoke、support access 或其他身份写 API。响应固定返回 `locks_acquired=false` 和 `executor_enabled=false`,页面不得提供执行按钮。 + +### 25.1 只读复核规则 + +- 复核操作存储状态、有效到期状态和两名不同的当前有效平台管理员审批;请求人审批不计入 quorum。 +- 复核审批有效期、审批 preview hash、审批时保存的平台权限 revision,以及审批人当前账号、成员和平台权限状态。 +- 使用提交时保存的原始 `preview_expires_at` 重新构建 preview 并比较 hash;新增加法迁移 `20260803_0042` 保存该字段。历史记录保持 `NULL`,不猜测回填,稳定阻断码为 `preview_verification_material_missing`。 +- 逐项比较批准时的 expected revision 与当前身份实体 revision,并再次运行当前目标状态校验。任一漂移或当前状态非法均使 `ready=false`。 +- preflight 不调用过期物化,不追加事件/outbox,不修改 operation/approval 或身份实体,也不获取真实 `FOR UPDATE` 锁。到期只体现在 `effective_status=expired` 和 blocker 中。 + +稳定 blocker 包括 `operation_expired`、`operation_not_approved`、`approval_quorum_missing`、`approval_expired`、`approval_preview_mismatch`、`requester_approval_present`、`approver_not_current`、`approval_permission_revision_mismatch`、`preview_verification_material_missing`、`preview_verification_material_invalid`、`current_state_invalid`、`preview_hash_mismatch`、`expected_revisions_missing`、`expected_revisions_invalid`、`expected_revisions_mismatch`、`revision_mismatch` 和 `unsupported_lock_resource`。 + +### 25.2 固定锁计划 + +preflight 只返回未来执行事务必须遵循的描述性锁计划,锁模式固定标注为 `for_update`,但本接口不真正取锁。同一资源类型按 `entity_id` 排序,跨类型顺序固定为: + +1. `privileged_operation` +2. `privileged_approval_set` +3. `identity_account` +4. `workspace` +5. `tenant_user` +6. `workspace_membership` +7. `identity_account_credential` +8. `tenant_user_credential` + +未来执行器必须在单一 PostgreSQL 事务内按该顺序重新取锁并重新执行全部检查;不得把本次 GET 的 `ready=true` 当作可复用授权票据。`ready` 只表示查询时刻检查通过,不表示身份数据已更改、执行 API 已获准上线或锁已被持有。 + +### 25.3 展示、验收与边界 + +- Web 详情只读展示检查结论、稳定 blocker、revision 对比、固定锁计划、原始 preview 有效期和两个关闭标志;客户端只发 GET。 +- SQLite 特权操作专项 `23/23`、安全投递契约 `6/6`、迁移专项 `2/2`(组合 `31/31`)、Web 全量源码测试 `37/37`、页面专项 `5/5`、TypeScript、Python compileall 和 Alembic 单一 head `20260803_0042` 已通过。 +- 真实 PostgreSQL 隔离 schema 专项 `1/1` 通过:双审批后 ready;目标 revision 漂移后返回 `revision_mismatch`;两次查询前后 operation、approval、event、outbox 及账号、工作空间、用户、成员关系全字段快照完全一致;随机测试 schema 已清理。 +- OpenAPI 只增加一个 GET,preflight 路径不存在 POST/PUT/PATCH/DELETE;全局破坏性身份写路径仍为 0。 + +本检查点不是执行器上线批准。下一步在任何真实身份写入前,仍需独立设计并评审事务执行状态机、固定锁的真实获取、幂等 execution attempt、失败补偿、outbox 副作用、support access 在线鉴权与自动到期,以及独立 rollback 预览/审批/演练。真实 transport 也继续受第 24 节门禁约束。 + +本地运行库已在停止 Contract API 后从 `0041` 升级到 `0042`;停服后的可校验备份为 `.runtime/db-backups/contract-api-20260802-before-0042-20260803-041600.sqlite3`。升级后 Contract API `8110` 重启并返回 `200`,Web `3021` 与 AI Platform `8111` 保持运行;运行态 OpenAPI 中 preflight 方法仅为 GET,身份域破坏性路径数量为 `0`。 + +## 26. 审批 SLA 临期与超期只读队列检查点(2026-08-03) + +本检查点增加平台运营可见性,不改变治理状态机。新增 `GET /api/v1/identity/privileged-approval-sla/overview` 和 `GET /api/v1/identity/privileged-approval-sla/queue`;两个接口只读取 stored `pending_approval` 操作、审批和审批人当前有效性,不调用过期物化、claim、retry 或身份执行逻辑。 + +### 26.1 SLA 口径与最小披露 + +- 默认观察窗口为最近 `7` 天创建的待审批操作,可配置 `1..31` 天;默认临期阈值为 `3600` 秒,可配置 `60..86400` 秒。 +- `expires_at <= generated_at` 为 `breached`;未超期且进入阈值为 `due_soon`;其余为 `on_track`。队列固定按 `breached -> due_soon -> on_track`、`expires_at`、`operation_id` 排序。 +- 有效审批计数沿用 preflight 规则:审批未过期、preview hash 一致、审批时 permission revision 未漂移,且审批人当前账号、用户、成员关系和平台权限仍有效。响应不披露审批人。 +- overview 仅返回生成时间、观察窗口、临期阈值、总量与三档计数。queue 仅返回不透明 operation ID、operation type、三档状态、有效/所需/剩余审批数、创建/到期时间和距到期秒数。 +- 禁止返回 reason、ticket、target/evidence refs、审批评论、actor、邮箱、姓名、账号/用户/成员 ID、preview、revision、payload 或凭据类字段。 + +### 26.2 只读与运营边界 + +- 超期只报告 `breached`,不把 operation 落库为 `expired`,不追加 event/outbox,也不修改 approval 或身份实体。 +- 两个 GET 仅允许当前有效平台管理员,并纳入 `identity.privileged_approval_sla.read` 敏感 `S3` 反滥用策略;不存在 POST/PUT/PATCH/DELETE、人工处置或身份动作。 +- Web 治理页增加审批时效摘要、状态筛选、分页队列、有效/剩余审批数和到期提示。页面只调用 GET,不增加 approve/reject、execute、retry 或 requeue 动作。 +- 观察窗口外的记录仍保留在治理操作、事件和审计证据中;本检查点不删除、归档或改写历史记录。运营值班应扩大窗口复核历史积压,不得把窗口过滤解释为记录不存在。 + +### 26.3 验收证据 + +- SQLite 特权身份、反滥用、迁移和安全投递组合 `45/45` 通过;其中 SLA 定向覆盖权限、三档边界、窗口、稳定排序、分页、过滤、有效审批语义、最小披露和查询前后治理快照完全一致。 +- 真实 PostgreSQL 隔离 schema `1/1` 通过,覆盖 `breached/due_soon/on_track`、最小字段扫描、查询前后 operation/approval/event/outbox 快照一致性及测试 schema 清理。 +- Web 全量源码测试 `38/38`、SLA 所在页面专项 `6/6`、TypeScript、Python compileall、Alembic 单一 head `20260803_0042` 和 `git diff --check` 通过。 + +本阶段不新增迁移,不启用 transport、身份执行器、support access 或任何 execute/rollback/merge/split/recover/grant/revoke 写入口。`breached` 是运营告警状态,不是已执行、已拒绝或已物化过期状态。 + +## 27. 跨时间窗长期审批积压只读可见性检查点(已验收) + +本检查点只补齐审批 SLA 的跨时间窗运营可见性,不改变 operation/approval 状态机,不物化 `expired`,不引入任何身份执行或处置写入口。目标是让值班人员区分“当前观察窗口内的待审批风险”和“窗口外仍未完成的长期积压”,避免把窗口过滤误解为数据不存在。 + +### 27.1 只读契约与全量/窗口口径 + +- overview 同时返回当前查询时刻、查询口径、临期阈值、窗口天数,以及窗口内 pending 数、全量 pending 数、窗口外 pending 数、窗口外已超期数和最老 pending 年龄。全量指标只统计存储状态仍为 `pending_approval` 的操作,不调用 `list_operations` 或 `_materialize_expired_operations`。 +- queue 提供 `scope=window|all`。`scope=window` 沿用窗口天数筛选,`scope=all` 查询所有仍处于 `pending_approval` 的记录;两者均不修改操作、审批、事件、outbox 或身份实体。默认维持 `scope=window`,非法 scope 返回稳定 `422`,响应回显实际 scope。 +- `breached` 只表示当前查询时刻 `expires_at <= generated_at`;它不是状态迁移、执行授权、拒绝结果或已写入的 `expired`。`due_soon` 的边界必须在 API、Web 和测试中保持一致。 +- 队列仍按风险级别、到期时间、不可变 operation ID 稳定排序并有界分页;全量查询不得泄露 reason、ticket、target/evidence、actor、审批评论、邮箱、姓名、账号/成员身份、preview、revision、payload 或凭据字段。 + +### 27.2 Web、权限与反滥用 + +- 仅当前有效平台管理员可读取 overview/queue;普通个人用户、企业普通成员、企业管理员、已撤销平台权限的旧会话和未登录请求分别返回既有认证/授权错误,不因 scope 切换扩大权限。 +- Web 只提供“窗口内/全部积压”分段控件、摘要指标和只读分页队列;客户端只发 GET,不出现 approve/reject、execute、retry、requeue、cancel 或身份动作按钮。 +- 两个 GET 归入敏感 `S3` 反滥用策略,限流与审计只记录路由、查询口径、阈值和不透明关联 ID,不记录原始目标、reason、ticket、审批评论或身份详情。 + +### 27.3 验收证据与明确非目标 + +- SQLite/API 测试覆盖窗口内/窗口外/全量计数、空队列、超期和临期边界、scope 非法值、稳定排序分页、权限拒绝、最小披露及 GET 前后 operation/approval/event/outbox/身份快照不变。 +- 真实 PostgreSQL 隔离 schema 专项必须复核全量与窗口查询的一致计数、并发只读安全、快照不变和测试 schema 清理;不得以 SQLite 结果替代真实数据库证据。 +- Web 页面专项、Web 全量源码测试、TypeScript、Python compileall、Alembic 单一 head 和 `git diff --check` 均需单列结果;运行态 OpenAPI 中本检查点只新增 GET,破坏性身份写路径保持 `0`。 +- 本检查点不新增迁移,不新增 execute/rollback/merge/split/recover/grant/revoke/support access 或人工审批/消息处置写接口;不启用 transport,不连接 SMTP、工单、Webhook,不改变 retention、归档和删除策略。身份执行器继续关闭。 + +验收结果(2026-08-03):SQLite 特权身份、反滥用、迁移和安全投递组合 `45/45`,真实 PostgreSQL `192.168.3.90:5432/yuqei_contract` 隔离 schema 专项 `1/1`,Web 全量源码测试 `39/39`、页面专项 `7/7`,TypeScript、Python compileall、Alembic 单一 head `20260803_0042` 与 `git diff --check` 均通过。运行态 OpenAPI 中 overview/queue 均仅注册 GET,身份域 execute/rollback/merge/split/recover/grant/revoke/support-access/retry/requeue/ack 破坏性写路径为 `0`。已登录浏览器在桌面和 `390x844` 窄屏下完成 scope 切换、文本容纳、无页面水平溢出和控制台 error `0` 验证;Contract API、Web 与 AI API 继续运行,身份执行器和 transport 保持关闭。 + +## 28. 追加式执行尝试账本与失败状态机检查点(已验收) + +本检查点建立未来统一执行器所需的内部审计账本,但不实现执行器、身份写入、补偿动作或任何运行时写接口。账本只记录内部调用方显式登记的尝试和状态事件;当前状态从最后一条不可变事件派生,不写回 operation、approval、身份实体或 outbox。 + +### 28.1 追加式存储与幂等契约 + +- `privileged_identity_execution_attempts` 保存不可变尝试根:`attempt_id`、`operation_id`、递增 `attempt_no`、幂等键、规范化请求/preview/preflight 的 SHA-256、脱敏 `trace_id` 和创建时间。注册仅接受已审批且未过期、请求哈希和 preview 哈希与原 operation 一致的记录。 +- `privileged_identity_execution_attempt_events` 保存不可变状态事件:事件类型、前后状态、稳定错误码、脱敏快照及其 SHA-256、确定性 event payload hash、`trace_id` 和发生时间。相同尝试内 `(attempt_id, sequence_no)` 与 `(attempt_id, event_payload_hash)` 均唯一。 +- 相同幂等键和相同材料返回原 attempt;相同幂等键绑定不同材料返回稳定 `409`。相同事件材料重放返回原状态,不增加事件行。PostgreSQL 注册在所属 operation 行上串行化 attempt number 分配;事件追加按 attempt 行串行化。 +- attempt 根和事件在 SQLite/PostgreSQL 均由数据库触发器禁止 UPDATE/DELETE;外键使用 `NO ACTION`。应用层自动建表路径使用同等保护,不能以绕过 Alembic 的方式获得可变账本。 + +### 28.2 精确状态机、错误与快照边界 + +- 唯一合法转换为 `prepared -> running|aborted`、`running -> succeeded|failed|compensation_required`、`compensation_required -> compensated|compensation_failed`。`succeeded`、`failed`、`compensated`、`compensation_failed`、`aborted` 均为终态,不得继续迁移。 +- event type 与 `status_after` 固定映射;调用方必须声明预期 `status_before`,持久化层以数据库中最后事件复核,漂移返回稳定冲突,不猜测或自动跳转状态。 +- 失败只接受有限稳定错误码,不接收原始异常、堆栈、Provider 响应、凭据或自由文本。审计快照使用字段和类型双重 allowlist,只允许状态、操作类型、计数、哈希、稳定结果码及布尔标志;target、evidence、actor、邮箱、姓名、身份详情和凭据字段会被拒绝。 +- `trace_id` 只接受安全格式;不合规值转为不可逆指纹。账本不保存或披露原始请求、preflight、快照外材料和身份写入结果。 + +### 28.3 只读可见性与运行边界 + +- 运行时只新增 `GET /api/v1/identity/privileged-operations/{operation_id}/execution-attempts`,仅当前有效平台管理员可读,继续受 `identity.privileged_operations.read` 敏感 `S3` 策略保护。 +- 响应只包含 attempt ID、operation ID、序号、派生状态、事件数、最近稳定错误码、脱敏 `trace_id`、创建时间和最近事件时间,并固定 `executor_enabled=false`。不返回幂等键、request/preview/preflight hash、快照、原始错误、target/evidence、actor 或凭据。 +- Web 只展示执行尝试历史的加载、空态、不可用态和最小字段,不提供 execute、retry、compensate、abort、rollback 或身份动作按钮;客户端只发 GET。 +- OpenAPI 中 execution-attempts 只有 GET。身份域 execute/rollback/merge/split/recover/grant/revoke/support-access/retry/requeue/ack 等破坏性写路径保持 `0`;内部登记和事件追加方法不得注册为 FastAPI route。 + +### 28.4 验收证据与后续门槛 + +- SQLite 特权身份、反滥用、迁移和安全投递组合 `47/47` 通过;其中执行账本专项覆盖注册/事件重放、幂等冲突、敏感快照和原始错误拒绝、精确终态、补偿/中止合法路径、数据库不可变保护、最小披露、权限和 GET 前后治理/账本快照不变。 +- Alembic head 为 `20260803_0043`;迁移专项 `2/2` 覆盖表、列、索引、外键、唯一/检查约束、SQLite 触发器,以及 `head -> 0042 -> head` 降级/重升级。所有 PostgreSQL 标识符均不超过 63 字符。 +- 真实 PostgreSQL `192.168.3.90:5432/yuqei_contract` 隔离 schema `1/1` 通过,覆盖同幂等键并发只创建一个 attempt/prepared event、相同状态事件并发只追加一次、唯一约束和 UPDATE/DELETE 拒绝;随机 schema 已清理,凭据未落盘。 +- Web 全量源码测试 `40/40`、页面专项 `8/8`、TypeScript 和 Python compileall 通过。运行态浏览器验收需在 Contract API 升级到 `0043` 并重启后复核桌面/窄屏、空态或已有尝试、控制台错误和 OpenAPI。 + +下一阶段仍不开放 execute API。优先实现内部固定锁真实获取和锁内二次 preflight 复核契约,证明锁顺序、锁超时、revision 漂移、审批/权限失效和事务释放;该阶段只允许无身份 mutation 的验证事务。身份执行器、真实 support access、补偿执行、rollback 和人工账本写入口继续关闭,直到身份事务原子性、失败补偿、outbox 副作用和安全评审全部独立验收。 + +## 29. 固定锁真实获取与锁内二次 preflight 检查点(已验收,2026-08-09) + +本检查点把第 28 节的追加式账本与第 25 节的描述性锁计划连接为内部验证事务,但不开放执行器、不写入身份实体,也不注册 execute API。 + +### 29.1 内部验证事务契约 + +- 新增内部 `recheck_execution_preflight_under_lock()`,仅允许当前有效平台管理员上下文调用;方法不在 FastAPI 路由、OpenAPI 或 Web 客户端中暴露。 +- PostgreSQL 事务先设置受界限的 `lock_timeout`,再按 operation、approval set、identity account、workspace、tenant user、workspace membership、global credential、legacy credential 的固定 rank 获取行锁;同类资源按 ID 排序。 +- 为防止审批人或调用者权限在锁内失效,实际锁集合包含调用者及所有审批人的账号、工作空间、用户和成员关系保护行;公开 GET 锁计划仍只披露原始 preview 资源。 +- 所有锁获取完成后显式失效 ORM identity map,再执行审批、到期、preview hash、permission revision、expected/current revision 和当前目标状态二次复核;锁等待后的读取不得复用锁前缓存。 +- 事务只执行 SELECT、`SET LOCAL lock_timeout` 和内存校验,返回前验证 session 没有新增、脏或删除对象;事务无论 ready 或 blocker 均提交空事务并释放全部锁。 +- 锁超时返回稳定 `423 execution_lock_timeout`,超时事务回滚并释放已经取得的 operation 和身份资源锁;非法超时参数返回稳定 `422 execution_lock_timeout_invalid`。 + +### 29.2 验收证据与边界 + +- 真实 PostgreSQL `192.168.3.90:5432/yuqei_contract` 隔离 schema `1/1` 通过,覆盖真实 `FOR UPDATE` 顺序、相反 source/target 并发无死锁、50ms 锁超时、超时后锁释放、锁内 revision 漂移、审批过期、审批人失权以及事务前后治理和身份快照不变;隔离 schema 已清理,凭据未落盘。 +- Web 全量源码测试 `41/41`、页面专项 `9/9`、TypeScript、Python compileall 和 `git diff --check` 通过;运行态 execution-attempts 仅 GET,身份破坏性写路径为 `0`。 +- 桌面运行态和 `390x844` 窄屏治理页均无水平溢出,详情页显示“执行尝试历史”和“只读 · 执行器关闭”,控制台无应用 error;浏览器窄屏覆盖已恢复默认 viewport。 + +下一阶段仍不开放 execute API。优先建设确定性身份变更计划与逆向补偿计划的内部生成和哈希,再在隔离 PostgreSQL 中做事务演练、故障注入、原子回滚和 outbox 一致性验证;任何真实身份 mutation、support access、transport、rollback 或人工账本写入口继续关闭。 + +## 30. 确定性身份变更与逆向补偿计划检查点(已交付,2026-08-09) + +本检查点只建立未来统一执行器可消费的内部计划材料,不执行身份 mutation,不写数据库,不投递 outbox,也不注册 execute API。support access 仍明确关闭。 + +### 30.1 计划契约 + +- 新增内部 `privileged_identity_plans` 模块和 `build_privileged_identity_change_plan()` 纯函数,覆盖 `account_merge`、`account_split`、`membership_recovery`、`platform_admin_grant`、`platform_admin_revoke` 五类身份变更;`support_access_grant` 返回稳定的 `support_access_plan_disabled`。 +- 输入复用既有 `operation_type`、`target_refs`、`expected_revisions` 和固定 `lock_plan`。target refs 必须精确匹配操作字段,未知、缺失、重复、同账号、非法 partition、非法 recovery type、敏感字段和 stale/blocked lock 均 fail closed。 +- 计划步骤和资源类型按固定操作模板生成;锁按 `privileged_operation`、approval set、资源 rank 和同类 resource ID 排序。输入字典、revision 和 lock 序列重排不改变输出。 +- 计划快照不保留原始 target ref 值,只保留每个字段的 SHA-256 指纹;expected revision、锁当前/期望 revision 和固定步骤均纳入正向 `plan_hash`。补偿步骤严格按正向步骤逆序,并产生独立 `compensation_plan_hash`。 +- repository 仅增加内部 `build_internal_change_plan()`,要求 fresh ready preflight;该方法无 FastAPI 绑定,禁止写入 operation、身份表、event、outbox 或 execution ledger。 + +### 30.2 验收证据与后续门槛 + +- 计划专项测试 `9/9` 通过,覆盖五类操作、正向/逆向顺序、顺序无关确定性 hash、未知/不完整/敏感 target refs、support access 关闭、stale/缺失 lock 拒绝、hash 篡改检测,以及 approved operation 的 repository 只读集成。 +- Contract API Python `compileall` 和 `git diff --check` 通过;未新增迁移,运行态 OpenAPI 路由集合不变,身份执行器和所有破坏性写接口继续关闭。 + +下一阶段才可在隔离 PostgreSQL 中演练计划校验、故障注入、事务原子回滚和 outbox 一致性;在该专项与独立安全评审完成前,不得开放任何 execute、rollback、transport、support access 或补偿执行入口。 + +## 31. 隔离 PostgreSQL 事务、故障注入与 outbox 一致性检查点(已验收,2026-08-09) + +本检查点仅在测试创建的随机 PostgreSQL schema 内演练未来身份事务边界,不增加运行时执行器、数据库迁移或 API。测试结束后精确删除 schema,真实运行数据和当前 SQLite 运行库均不参与演练。 + +### 31.1 演练契约 + +- 使用第 30 节的 `build_internal_change_plan()` 先生成并校验确定性计划,确认 `executor_enabled=false`、正向计划 hash 和补偿计划 hash 均存在;计划构建本身保持只读。 +- 在同一 PostgreSQL 事务内演练目标平台权限投影、追加 operation event 和对应 outbox message;成功提交后,同一 operation 的 event 与 outbox 数量保持一一对应。 +- 第一故障点位于身份投影 flush 之后、event/outbox 之前;注入异常后身份、operation、approval、event、outbox 和 execution ledger 全字段快照恢复到事务前状态。 +- 第二故障点位于 event 与 outbox 均已 flush 之后;注入异常后身份投影、event 和 outbox 同时回滚,不允许孤立 event、孤立 outbox 或部分身份变更。 +- 演练直接复用真实 PostgreSQL 外键、唯一约束和事务语义,但只作用于随机隔离 schema。测试代码没有注册 execute/rollback/compensate API,也没有把测试故障注入器接入运行时依赖图。 + +### 31.2 验收证据与后续门槛 + +- `192.168.3.90:5432/yuqei_contract` 隔离 PostgreSQL 专项 `1/1` 通过;覆盖计划校验、身份更新后故障、event/outbox 后故障、原子回滚、成功提交的一一对应关系和事务后快照。 +- 专项执行后 `privileged_governance_%` schema 残留数量为 `0`;连接凭据只通过临时环境变量使用,未写入仓库、测试文件或文档。 +- 本阶段仅新增测试覆盖,不增加 migration、生产 worker、transport、support access、人工账本写入口或 FastAPI 路由;身份执行器仍关闭。 + +下一阶段按顺序建设 support access 的在线授权检查、自动到期和紧急撤销,但仍先保持内部/受控验证,不开放通用身份代入或密码/会话共享;完成后必须单独进行身份执行与支持访问安全评审,再决定是否具备任何受限发布条件。 + +## 32. support access 在线校验、自动到期与紧急撤销检查点(已验收,2026-08-09) + +本检查点只冻结未来支持访问的 validation-only 安全策略,不签发访问令牌、会话或 impersonation context,不接入 FastAPI、Web、worker 或 transport。所有校验结果固定 `access_granted=false`,即使当前材料满足策略也只返回 `policy_satisfied=true` 和 `support_access_executor_disabled`。 + +### 32.1 validation-only 策略 + +- 仅接受已审批、两名有效审批、preview hash 当前有效且不超过 24 小时的 support access 材料;scope 只能来自 `audit.read`、`contract.comment`、`contract.read`、`review.read`、`template.read`。 +- 原始 `subject_reference` 不进入策略快照,仅保存 SHA-256 指纹;账号、工作空间、scope、审批时 revision、起止时间和 operation ID 共同绑定 `policy_hash`。未知字段、额外 revision、缺失 revision、重复 revision、非法 scope 和非法时区均 fail closed。 +- 每次检查都重新验证调用账号/工作空间、请求 scope、当前账号与工作空间状态、current revision、起效时间、到期时间、策略 hash 和撤销 hash;账号/工作空间停用或 revision 漂移立即阻断。 +- `checked_at >= expires_at` 自动返回 `expired`,不依赖后台任务物化状态;紧急撤销使用固定原因码、不可改写的 `revocation_hash`,相同材料重放幂等,不同材料重写被拒绝。 +- 模块不接受 activation flag,不存在可把 `access_granted` 改为 true 的参数;没有密码、cookie、token、session、用户代入或跨工作空间身份上下文。 + +### 32.2 验收证据与边界 + +- support access 策略专项 `15/15` 通过,覆盖确定性材料、scope 去重、最小披露、账号/空间/scope/状态/revision 在线阻断、到期边界、紧急撤销幂等与不可改写、审批门槛、24 小时上限和 policy/revocation hash 篡改。 +- 与确定性身份计划和 repository 只读集成组合 `24/24` 通过;Python compileall 和 `git diff --check` 通过。 +- 源码扫描确认策略构建、检查和撤销函数未被 API 或 `main.py` 引用;本阶段没有 migration、route、Web 控件、grant/revoke 写入口或持久化支持访问记录。 + +下一步必须进行独立安全评审,确认 validation-only 策略不能被误当作授权结果,并继续检查 TOCTOU、时钟来源、撤销传播、持久化状态、强认证、会话绑定、最小 scope 和审计留痕。评审通过前真实 support access 和身份执行器均不得启用。 + +## 33. 特权身份与 support access 独立安全评审(No-Go,2026-08-09) + +独立评审报告见 `V2-privileged-identity-security-review-2026-08-09.md`。评审确认当前锁、计划、隔离事务和 validation-only 策略可以继续作为开发基础,但不构成真实身份执行或 support access 的发布批准。 + +P0 阻断项包括:durable grant/revocation 与跨节点一致性缺失、检查和资源访问之间 TOCTOU 未闭合、可信服务端/数据库时间未绑定、强认证与会话绑定缺失、紧急撤销传播和 kill switch 未演练、逐请求不可变审计与告警缺失、统一 deny-by-default enforcement point 缺失,以及隐私/法律门禁未完成。 + +运行态源码和 OpenAPI 复核:support access validation-only 模块没有 API/`main.py` 引用,`access_granted=true` 路径为 `0`,危险身份写路由为 `0`,plan 路由为 `0`,execution-attempts 仍仅 GET。评审结论保持:真实身份执行器、support access、transport、共享会话和人工绕过入口全部关闭。 diff --git a/project-docs/V2-privileged-identity-security-review-2026-08-09.md b/project-docs/V2-privileged-identity-security-review-2026-08-09.md new file mode 100644 index 00000000..ad1167e2 --- /dev/null +++ b/project-docs/V2-privileged-identity-security-review-2026-08-09.md @@ -0,0 +1,54 @@ +# AI 合同平台 V2 特权身份与支持访问安全评审 + +> 日期:2026-08-09 +> 范围:固定锁复核、确定性身份计划、隔离 PostgreSQL 事务演练、support access validation-only 策略 +> 发布结论:**No-Go,真实身份执行器与 support access 不得启用** + +## 1. 已验证的安全属性 + +- 运行态不存在 execute、rollback、merge、split、recover、grant、revoke、support-access、retry、requeue 或 ack 路由;execution-attempts 仍为 GET-only。 +- 固定锁和锁内二次 preflight 已在真实 PostgreSQL 隔离 schema 验证,revision 漂移、审批过期、审批人失权和锁超时均 fail closed。 +- 确定性计划只保存 target ref 指纹,正向/补偿步骤和双 hash 可检测篡改;support access 不生成身份 mutation plan。 +- 隔离事务演练覆盖身份更新后故障和 event/outbox 后故障,身份、event、outbox 同时回滚;随机 schema 清理后残留为 0。 +- support access 策略只允许固定 read/comment scope,逐次检查主体、工作空间、scope、状态、revision、时间、policy hash 和 revocation hash;所有结果固定 `access_granted=false`。 + +## 2. P0 发布阻断项 + +1. **无运行时可信授权源**:当前策略材料是纯内存输入,没有持久化 grant、撤销记录、单一真相版本或跨实例一致性协议。任何接入都可能读取陈旧授权。 +2. **TOCTOU 未闭合**:validation-only 检查与未来资源访问不在同一受控事务/策略执行点;检查后账号、工作空间、scope、revision 或撤销状态仍可能变化。 +3. **可信时间未定义**:`checked_at` 由内部调用方传入,尚未绑定数据库时间或受保护的服务端时钟;不能作为真实自动到期依据。 +4. **强认证与会话绑定缺失**:没有 step-up/MFA、设备与会话绑定、短时凭证、不可转移 audience、网络/设备约束或重认证窗口。 +5. **撤销传播缺失**:紧急撤销只有纯函数语义,没有 durable revoke、缓存失效、跨节点传播 SLA、拒绝列表或 kill switch 演练。 +6. **逐请求审计缺失**:没有对每次支持访问记录资源、scope、决策、策略版本、操作者、时间、结果和关联工单的不可变审计/outbox;也没有异常访问告警。 +7. **授权执行面缺失**:没有专用 policy enforcement point,无法证明所有合同、审查、模板和审计读取路径均经过同一 deny-by-default 检查。 +8. **隐私与法律门禁未完成**:支持人员可见字段、最小数据集、数据驻留、客户通知、保留期、导出/下载限制和具名法务批准尚未验收。 + +## 3. 后续实现准入标准 + +- 加法迁移建立不可变 grant/revocation 状态、版本与审计模型;任何 UPDATE/DELETE、过期或撤销竞争均有数据库约束和并发测试。 +- enforcement point 使用服务端/数据库可信时间,在每个受保护请求上在线读取或验证当前 grant、scope、revision、撤销和会话绑定;默认拒绝,依赖不可用时不得降级放行。 +- 支持人员使用独立平台身份和强认证,不共享用户密码、cookie、refresh token 或现有会话;不得伪装成用户主体。 +- 短时凭证具备固定 audience、workspace、scope、grant ID、策略版本和不可转移绑定;自动到期和紧急撤销在所有节点达到明确 SLA。 +- 每次授权检查和资源访问生成最小披露、不可变、可关联的审计事件;告警、人工复核、导出管控、保留和删除策略通过运营演练。 +- 独立渗透/越权测试覆盖跨 workspace、scope 提升、时钟偏移、缓存陈旧、撤销竞态、重放、并发和依赖故障;全部 P0 阻断关闭后再进行发布评审。 + +## 4. 回归清单 + +- 有效材料仍不得在 validation-only 模式返回 `access_granted=true`。 +- 到期边界使用 `checked_at >= expires_at`,时区缺失或时钟异常 fail closed。 +- 账号/工作空间停用、revision 漂移、主体或 scope 不匹配立即拒绝。 +- policy hash、grant ID、revocation code/hash、scope 顺序、TTL 或时间篡改均拒绝。 +- 相同撤销重放幂等,不同材料不能改写历史撤销。 +- API/OpenAPI 不出现 support access、grant、revoke、execute 或 rollback 写入口。 +- 真实 PostgreSQL 演练只使用随机 schema,异常和成功路径后均精确清理,凭据不得落盘。 +- Web 不出现执行、代入、重试、撤销或共享会话控件。 + +## 5. 证据摘要 + +- Contract API 全量基线:`250 passed, 1 skipped`;真实 PostgreSQL stage 19 专项随后单独 `1/1` 通过。 +- 确定性计划:`9/9`;计划/投递/反滥用组合:`54/54`。 +- 隔离 PostgreSQL 原子性演练:`1/1`,残留 schema:`0`。 +- support access validation-only:`15/15`;与计划层组合:`24/24`。 +- Python compileall、`git diff --check`、三服务健康检查通过;运行态危险身份路由 `0`、plan 路由 `0`。 + +本报告批准继续开发安全基础设施,不批准启用真实身份执行、支持访问、transport、共享会话或人工绕过入口。 diff --git a/project-docs/V2-product-audit-and-development-backlog-2026-08-01.md b/project-docs/V2-product-audit-and-development-backlog-2026-08-01.md index 90823d89..06967b9d 100644 --- a/project-docs/V2-product-audit-and-development-backlog-2026-08-01.md +++ b/project-docs/V2-product-audit-and-development-backlog-2026-08-01.md @@ -584,3 +584,132 @@ legal_name: string | null 当前单一 Alembic head 为 `20260803_0041`;Python compileall 和 `git diff --check` 通过。本阶段不新增 API、DTO、Web 或 transport 逻辑,身份执行器、真实 support access、人工 outbox retry 及 execute/rollback/merge/split/recover/grant/revoke 继续关闭。下一阶段仍须先完成 transport 选择、消费者幂等契约与外发关联风险评审,不得把数据库完整性加固解释为真实副作用或身份写入获准上线。 本地运行库已在停服备份后由 0040 升级到 0041;升级前 outbox、跨操作错配和非法租约计数均为 `0`,备份文件为 `.runtime/db-backups/contract-api-20260802-before-0041-20260803-012502.sqlite3`。补充 SQLite 触发器后已完成 0041 downgrade/upgrade 循环,复合索引、复合外键、租约 CHECK 和两条关联拒绝触发器复核通过;Contract API 重启并在 `8110` 返回 `200`,Web `3021` 与 AI Platform `8111` 保持运行,现有 Web 会话未因本次迁移重启。 + +### 9.33 第十四阶段:外发安全契约与消费者幂等参考实现(已交付,2026-08-03) + +本阶段完成 9.32 要求的 transport 前置契约,但不选择或连接真实 provider。目标是冻结外发最小字段、认证和 ACK 语义,并用无网络参考消费者验证 at-least-once 重投不会重复业务副作用;它不是 transport、通知或身份执行器上线阶段。 + +1. **P0-EGRESS-1 最小安全 envelope(完成)**:新增 `privileged-identity-egress.v1`,只包含 contract/key ID、外部 delivery key、effect/event/operation type、状态转换和发生时间。禁止外发 message/event/operation ID、内部幂等键、trace ID、目标指纹、快照及哈希、actor、工作空间/用户 ID、reason、ticket、邮箱和凭据。 +2. **P0-EGRESS-2 域隔离认证(完成)**:外部 delivery key 由独立、稳定的幂等密钥对内部幂等键执行域隔离 HMAC;规范化 envelope 生成 SHA-256 body digest,再由带 key ID 的可轮换签名密钥签名。两类密钥均少于 32 bytes 时在消息 claim 前拒绝启动;签名轮换会改变 key ID/signature,但同一消息的 delivery key 在租约重领和 worker 重启后保持不变。 +3. **P0-EGRESS-3 类型化 receipt(完成)**:transport 契约从 `deliver(message) -> None` 收口为 `deliver(envelope) -> receipt`,结果固定为 `accepted/duplicate/retryable_failure/permanent_failure`。只有 delivery key 与 digest 同时匹配的 accepted/duplicate 才确认 delivered;retryable 进入既有退避,permanent、未知 receipt 或 ACK 不匹配进入 dead-letter,并仅保存稳定错误码。 +4. **P0-EGRESS-4 消费者幂等参考(完成)**:参考消费者按 `(consumer_id, delivery_key)` 保存 receipt;相同 key/digest 返回 duplicate,不重复副作用;相同 key/不同 digest 永久拒绝为 `idempotency_conflict`。签名失败、未知 schema major 和载荷超限均永久拒绝;可重试依赖失败返回稳定 `consumer_unavailable`。该内存实现只用于契约和 ACK 丢失测试,生产消费者必须将 receipt 与业务副作用置于同一持久化事务。 +5. **P0-EGRESS-5 封闭测试旁路(完成)**:移除 worker 的 `run_once(deliver=...)` 回调旁路,所有启用场景必须显式注入类型化 transport 和 delivery contract。默认 transport 继续 disabled,`run_once()` 返回 0 且不 claim;未配置 contract 的 enabled transport 在 claim 前失败。 +6. **P0-EGRESS-6 回归证据(完成)**:新增契约与 worker 定向 `8/8`,特权身份操作 `20/20`,SQLite 0041 迁移 `2/2`,真实 PostgreSQL 隔离 schema `1/1`。PostgreSQL 覆盖并发认领、租约恢复前后 envelope/delivery key/digest/signature 稳定、敏感关联字段不外发、重试/dead-letter、0041 完整性和 schema 清理。 + +明确非目标:不增加迁移、API 或 Web 页面;不连接 SMTP、工单、Webhook 或其他真实网络端点;不新增人工 retry/requeue/ack;不回填历史消息;不开放 execute、rollback、merge、split、recover、grant、revoke、真实 support access 或任何身份写入口。发送语义仍为 at-least-once,不宣称 exactly-once。 + +下一阶段候选必须单独评审:一是审批 SLA/临期与超期只读队列,二是统一身份执行器的只读 preflight 和固定锁计划。任何真实 transport 仍须先完成固定目标 allowlist、TLS/服务认证、持久化消费者 inbox、背压/保留、访问审计、密钥轮换与外部 provider 验收;SMTP 不直接消费治理 outbox。 + +### 9.34 第十五阶段:统一执行器只读 preflight 与固定锁计划(已交付,2026-08-03) + +本阶段完成未来统一执行器的只读验证契约,不增加 execute API,不获取真实数据库写锁,不写账号、工作空间、成员关系、凭据、权限、operation、approval、event 或 outbox。 + +1. **P0-PREFLIGHT-1 可验证 preview 材料(完成)**:新增加法迁移 `20260803_0042`,新操作保存原始 `preview_expires_at`;历史操作保持 `NULL`,不推测回填并返回 `preview_verification_material_missing`。 +2. **P0-PREFLIGHT-2 审批时效与权限复核(完成)**:复核 operation 状态/到期、两名不同有效审批人、请求人隔离、approval expiry、preview hash、审批时 permission revision,以及审批人当前账号、用户、成员和平台权限状态。 +3. **P0-PREFLIGHT-3 revision 与当前状态复核(完成)**:重新构建原始 preview,比较 expected/current entity revision,并再次执行当前目标校验;缺失/非法 revision 材料、revision scope 不一致、漂移、缺失资源或当前状态非法均返回稳定 blocker,`ready=false`。 +4. **P0-PREFLIGHT-4 固定锁计划(完成)**:描述性顺序固定为 operation、approval set、identity account、workspace、tenant user、workspace membership、global credential、legacy credential;同类按 ID 排序。GET 始终返回 `locks_acquired=false`,未来执行事务不得复用 GET 结果代替锁内复核。 +5. **P0-PREFLIGHT-5 只读 API 与 Web(完成)**:只增加 `GET .../{operation_id}/execution-preflight`;页面展示 readiness、blockers、revision 和锁计划,同时明确 `executor_enabled=false`。没有执行、回滚或身份动作按钮。 +6. **P0-PREFLIGHT-6 自动化证据(完成)**:SQLite 特权操作 `23/23`、安全投递契约 `6/6`、迁移 `2/2`(组合 `31/31`)、Web 全量源码测试 `37/37`、页面 `5/5`、TypeScript、Python compileall、Alembic head `20260803_0042` 和真实 PostgreSQL 隔离 schema `1/1` 通过。PostgreSQL 验证 ready、revision 漂移、查询前后治理/身份全字段快照不变并清理测试 schema。 + +验收语义:`ready=true` 仅表示查询时刻审批、preview、权限、revision 和当前状态检查均通过,不是执行授权票据,不表示锁已持有或身份已变更;`approved` 仍只表示治理审批完成。preflight 不物化过期状态、不追加事件/outbox,OpenAPI 中该路径无 POST/PUT/PATCH/DELETE,全局破坏性身份写路径仍为 0。 + +后续 P0 backlog:独立评审真实事务执行状态机、execution attempt 幂等记录、固定锁真实获取、锁内二次复核、失败补偿、outbox 副作用、support access 在线鉴权/自动到期和独立 rollback 治理。在这些门槛完成前,身份执行器、真实 support access、execute/rollback/merge/split/recover/grant/revoke 和人工 outbox 写入口继续关闭。 + +运行环境已在停服备份后由 `0041` 升级到 `0042`,备份为 `.runtime/db-backups/contract-api-20260802-before-0042-20260803-041600.sqlite3`;Contract API `8110` 重启健康,Web `3021` 和 AI Platform `8111` 保持运行。运行态 OpenAPI 复核 preflight 只有 GET,身份域破坏性路径数量为 `0`。 + +### 9.35 第十六阶段:审批 SLA 临期与超期只读队列(已交付,2026-08-03) + +本阶段完成剩余 P1 运营可见性切片,不新增迁移或写入口,不物化超期状态。 + +1. **P1-SLA-1 只读 overview(完成)**:新增 `GET /identity/privileged-approval-sla/overview`,按可控观察窗口和临期阈值返回 `on_track/due_soon/breached` 总量,不返回操作正文或身份信息。 +2. **P1-SLA-2 稳定分页队列(完成)**:新增 GET-only queue,支持三档状态筛选和有界分页;按风险、到期时间、operation ID 确定排序,返回有效/所需/剩余审批数和时间字段。 +3. **P1-SLA-3 当前有效审批语义(完成)**:审批过期、preview hash 不一致、permission revision 漂移或审批人当前失权均不计入有效审批;不披露审批人、评论或身份字段。 +4. **P1-SLA-4 严格只读(完成)**:查询不调用 `_materialize_expired_operations`,超期只报告 `breached`;operation、approval、event、outbox 与身份数据不变,不 claim、retry 或产生外部副作用。 +5. **P1-SLA-5 Web 与 S3 门禁(完成)**:治理页增加摘要、筛选和分页队列,客户端仅发 GET;两个路由纳入敏感 `S3` 反滥用策略,没有审批、处置、执行或消息动作。 +6. **P1-SLA-6 自动化证据(完成)**:SQLite 组合 `45/45`、真实 PostgreSQL 隔离 schema `1/1`、Web 全量 `38/38`、页面 `6/6`、TypeScript、Python compileall、Alembic head `20260803_0042` 和 diff-check 通过。 + +运营口径:默认观察最近 `7` 天创建的 pending approval,临期阈值 `1` 小时;可将窗口扩至 `31` 天。窗口外记录继续存在于操作、事件和审计证据中,值班人员应定期扩大窗口复核历史积压。`breached` 只表示当前查询时刻已超过审批期限,不等于数据库状态已物化为 `expired`。 + +后续仍不得直接开放身份执行。下一阶段优先评审 execution attempt 的追加式幂等记录和失败状态机,或补齐长期积压跨窗口汇总;任一方向均不得引入真实身份写入、support access 或 execute API,除非另行完成锁内复核、补偿、outbox、回滚和安全评审。 + +### 9.36 第十七阶段:跨时间窗长期审批积压只读可见性(已交付,2026-08-03) + +本阶段补齐第十六阶段窗口外长期待审批记录的运营盲区,只扩展既有 overview/queue 的只读查询口径,不增加迁移、状态物化、处置动作或身份写入口。 + +1. **P1-SLA-AGE-1 全量与窗口外 overview(完成)**:overview 在既有窗口内三档计数之外,返回 `all_pending_total`、`outside_window_pending_count`、`outside_window_breached_count` 和 `oldest_pending_age_seconds`。所有指标基于 stored `pending_approval` 直接只读统计,不调用 `list_operations` 或 `_materialize_expired_operations`。 +2. **P1-SLA-AGE-2 queue scope(完成)**:queue 新增 `scope=window|all`,默认 `window`,响应回显 scope;`all` 可查看窗口外长期积压,继续支持三档筛选、有界分页及风险/到期时间/operation ID 的确定性排序。非法 scope 返回 `422`。 +3. **P1-SLA-AGE-3 最小披露与权限(完成)**:两个 scope 均只向当前有效平台管理员返回既有最小字段;禁止 reason、ticket、target/evidence、actor、审批人/评论、邮箱、姓名、账号/成员 ID、preview/revision、payload 和凭据字段。个人用户、企业成员/管理员及失权管理员不能读取全量积压。 +4. **P1-SLA-AGE-4 严格只读与 S3(完成)**:overview/queue 查询前后 operation、approval、event、outbox 和身份全字段快照一致,不物化超期、不 claim/retry、不产生外部副作用;两个 GET 继续受 `identity.privileged_approval_sla.read` 敏感 `S3` 策略保护。 +5. **P1-SLA-AGE-5 Web 分段口径(完成)**:治理页提供“最近 7 天/全部积压”分段控件,展示全量、窗口外、窗口外超期和最老积压年龄;scope 切换重置分页并只发 GET,不增加 approve/reject、execute、retry、requeue 或其他处置按钮。 +6. **P1-SLA-AGE-6 自动化证据(完成)**:SQLite/API 组合 `45/45`、真实 PostgreSQL 隔离 schema `1/1`、Web 全量 `39/39`、页面专项 `7/7`、TypeScript、Python compileall、Alembic 单一 head `20260803_0042` 和 `git diff --check` 通过。运行态 OpenAPI 中两个 SLA 路由均为 GET-only,身份破坏性写路径为 `0`;桌面与 `390x844` 浏览器回归通过,控制台 error 为 `0`。 + +验收口径:`all_pending_total = window_pending_total + outside_window_pending_count`;`outside_window_breached_count <= outside_window_pending_count`;空队列的最老年龄为 `null`,非空时为非负秒数。`scope=all` 只扩大待审批记录的时间范围,不扩大字段、租户、角色或动作权限。`breached` 仍只是查询时刻的运营告警分类,不等于数据库状态已写成 `expired`。 + +明确非目标:不新增数据库迁移,不回填、归档、删除或修改历史 operation/approval/event/outbox;不启用 transport、support access、身份执行器或人工消息/审批处置;不新增 execute/rollback/merge/split/recover/grant/revoke。下一阶段真实身份执行仍需独立完成 execution attempt 幂等、固定锁真实获取、锁内二次复核、失败补偿、outbox 副作用和 rollback 安全评审。 + +### 9.37 第十八阶段:追加式执行尝试账本与失败状态机(已交付,2026-08-03) + +本阶段只建设未来统一执行器的内部持久化和只读观测基础,不实现身份 mutation,不启用执行器,不新增运行时 attempt 写入口或 execute API。 + +1. **P0-ATTEMPT-1 不可变 attempt 根(完成)**:新增 `privileged_identity_execution_attempts`,保存 operation 关联、递增 attempt number、幂等键、request/preview/preflight SHA-256、脱敏 `trace_id` 和创建时间。仅已审批且未过期、request/preview 与 operation 一致的内部调用可以登记。 +2. **P0-ATTEMPT-2 追加式状态事件(完成)**:新增 `privileged_identity_execution_attempt_events`,保存确定性事件 ID、sequence、固定 event/status 映射、稳定错误码、脱敏前后快照及哈希、event payload hash 和 `trace_id`。current status 只从最后事件派生,不新增可变 current-status 列。 +3. **P0-ATTEMPT-3 精确失败状态机(完成)**:合法转换冻结为 `prepared -> running|aborted`、`running -> succeeded|failed|compensation_required`、`compensation_required -> compensated|compensation_failed`;所有其他转换拒绝,五个终态不可继续迁移。 +4. **P0-ATTEMPT-4 并发幂等与数据库保护(完成)**:相同幂等材料重放不增行,不同材料冲突;相同 event payload 重放不增行。数据库唯一约束保护 attempt number、idempotency key、event sequence 和 event payload;SQLite/PostgreSQL 触发器禁止 attempt/event UPDATE 与 DELETE,外键为 `NO ACTION`。 +5. **P0-ATTEMPT-5 脱敏快照与最小披露(完成)**:快照只接受固定状态、类型、计数、哈希、结果码和布尔字段;原始错误、target/evidence、actor、邮箱、身份详情和凭据被拒绝。只读 GET 仅返回 attempt 摘要,固定 `executor_enabled=false`,不返回幂等键、哈希或快照。 +6. **P0-ATTEMPT-6 GET-only Web 可见性(完成)**:治理详情页读取 execution attempts 并展示序号、派生状态、事件数、稳定错误码、`trace_id` 和时间;具备加载/空/不可用状态,无 execute/retry/compensate/abort/rollback 按钮。OpenAPI execution-attempts 只有 GET,内部 register/append 未暴露为 route。 +7. **P0-ATTEMPT-7 自动化证据(完成)**:SQLite/API 组合 `47/47`、迁移专项 `2/2`、真实 PostgreSQL 隔离 schema `1/1`、Web 全量 `40/40`、页面专项 `8/8`、TypeScript、Python compileall 和 `git diff --check` 通过。Alembic 单一 head 升至 `20260803_0043`;PostgreSQL 并发覆盖相同幂等键和相同事件仅落一份,隔离 schema 已清理,凭据未落盘。 + +验收口径:attempt 根与事件只能追加,不能 UPDATE/DELETE;状态、事件数、最后错误码和最近时间从事件派生;重放不增加行;非法转换、原始错误和敏感快照返回稳定错误;GET 前后 operation、approval、operation event、outbox、身份实体及 execution ledger 全字段快照一致;普通用户和失权管理员不可读;响应不含幂等键、request/preview/preflight hash、快照、raw error、target/evidence、actor 或凭据。 + +明确非目标:本阶段不获取真实身份资源锁,不执行 lock 内二次 preflight,不修改全局账号、工作空间投影、成员、凭据或平台权限,不投递 attempt outbox,不执行补偿/rollback,不启用 support access、transport 或 identity executor,不新增 execute/rollback/merge/split/recover/grant/revoke/retry/requeue/ack API。下一阶段优先建设内部固定锁真实获取与锁内二次复核,仍保持验证事务无身份写入且不提供 execute API。 + +### 9.38 第十九阶段:固定锁真实获取与锁内二次复核(已交付,2026-08-09) + +本阶段只把既有只读 preflight 和描述性 lock plan 落为内部 PostgreSQL 验证事务,不开放 execute API,不修改身份实体、operation、approval、event、outbox 或 execution ledger。 + +1. **P0-LOCK-1 内部锁事务(完成)**:新增内部 `recheck_execution_preflight_under_lock()`;仅当前有效平台管理员上下文可调用,方法未注册为 route。PostgreSQL 先设置 50-10000ms 有界 `lock_timeout`,再按固定 rank 获取 operation、approval set、身份主体和 preview revision 资源行锁,同类按 ID 排序。 +2. **P0-LOCK-2 权限主体保护(完成)**:实际锁集合补充调用者和审批人的账号、工作空间、用户及成员关系保护行,避免权限在锁内失效;公开 GET lock plan 仍保持最小披露,不将内部主体扩展伪装为执行授权。 +3. **P0-LOCK-3 锁内新鲜复核(完成)**:锁获取后失效 ORM identity map,再复核 operation 到期、审批 quorum/expiry、preview hash、permission revision、expected/current revision 和当前目标状态;ready 结果不是执行票据,`executor_enabled` 仍为 `false`。 +4. **P0-LOCK-4 只读与释放(完成)**:事务只允许 SELECT、`SET LOCAL lock_timeout` 和内存校验;检测 session 无新增/脏/删除对象。ready、blocker、422 参数错误和 423 锁超时均不产生身份或治理写入,超时回滚并释放已取得锁。 +5. **P0-LOCK-5 并发证据(完成)**:真实 PostgreSQL 隔离 schema `1/1` 覆盖真实 `FOR UPDATE` 顺序、反向账号合并输入无死锁、50ms 锁超时、超时后锁释放、revision 漂移、审批过期、审批人失权和全字段快照不变;随机 schema 已清理,凭据未落盘。 +6. **P0-LOCK-6 运行态守卫(完成)**:Web 全量 `41/41`、页面专项 `9/9`、TypeScript、Python compileall 和 `git diff --check` 通过;运行态 execution-attempts 仅 GET,身份破坏性写路径 `0`。桌面和 `390x844` 窄屏页面无水平溢出,详情页显示执行尝试历史及“只读 · 执行器关闭”,控制台无应用 error。 + +验收口径:锁前检查不得替代锁内复核;锁后读取必须使用新鲜 ORM/数据库状态;锁集合必须按固定 rank 和同类 ID 顺序获取;任何 blocker 或锁超时都 fail closed;验证事务前后 operation、approval、event、outbox、execution ledger 及身份表全字段快照一致。 + +明确非目标:本阶段不编译或执行真实身份 mutation,不生成或投递 attempt outbox,不启用 support access、transport、补偿执行或 rollback,不新增 execute/rollback/merge/split/recover/grant/revoke/retry/requeue/ack API。下一阶段先建设确定性身份变更计划与逆向补偿计划的内部生成和哈希,再进行隔离 PostgreSQL 事务演练、故障注入、原子回滚和 outbox 一致性验证。 + +### 9.39 第二十阶段:确定性身份变更与逆向补偿计划(已交付,2026-08-09) + +1. **P0-PLAN-1 纯内存计划模型(完成)**:新增不可变 Pydantic 计划模型和纯函数,覆盖五类身份变更;不增加 FastAPI 路由、execute API、迁移或身份写路径。 +2. **P0-PLAN-2 输入边界与最小披露(完成)**:严格校验 operation type、target refs、expected revisions 和固定锁计划;拒绝未知/不完整/敏感字段、重复 revision、同账号 partition、非法恢复类型、stale 或 blocked lock。计划不记录原始 target ref 值,仅保存字段指纹。 +3. **P0-PLAN-3 确定性步骤与锁序(完成)**:五类操作各有固定正向步骤;锁按既有 rank 与同类 ID 稳定排序;输入 map/list 顺序变化不影响 plan hash。 +4. **P0-PLAN-4 逆向补偿与双 hash(完成)**:补偿步骤严格为正向步骤逆序,正向 `plan_hash` 与独立 `compensation_plan_hash` 均由 `privileged_payload_hash` 计算;校验函数可检测步骤、锁或 hash 篡改。 +5. **P0-PLAN-5 内部接入与证据(完成)**:repository 增加无路由 `build_internal_change_plan()`,仅从 fresh ready preflight 构造计划;专项 `9/9`(含 repository 前后全字段快照不变)、Python compileall、`git diff --check` 通过。 + +验收口径:相同 operation/target/revision/lock 材料必须产生完全相同的步骤和双 hash;任何 blocker、revision/lock 漂移或敏感数据均不得生成计划;计划构建前后治理、身份、event、outbox 和 execution ledger 全字段快照不变;support access、transport、execute、rollback 和补偿执行继续关闭。下一阶段进入隔离 PostgreSQL 事务演练、故障注入、原子回滚和 outbox 一致性验证。 + +### 9.40 第二十一阶段:隔离 PostgreSQL 事务演练与 outbox 原子性(已交付,2026-08-09) + +1. **P0-TXN-1 随机 schema 演练(完成)**:只在 `192.168.3.90:5432/yuqei_contract` 的随机隔离 schema 中运行真实 PostgreSQL 事务;运行库、公共 schema 和生产身份数据不参与。 +2. **P0-TXN-2 计划前置校验(完成)**:演练前从已审批 operation 生成第 20 阶段确定性正向/补偿计划,确认执行器关闭和双 hash 有效,不把 plan 当作运行授权票据。 +3. **P0-TXN-3 双故障点原子回滚(完成)**:分别在身份投影 flush 后、event/outbox 前,以及 event/outbox 均 flush 后注入异常;两种情况下身份、治理、event、outbox 和 execution ledger 全字段快照均恢复到事务前状态。 +4. **P0-TXN-4 outbox 同事务一致性(完成)**:成功演练在同一事务提交身份投影、operation event 和 outbox;同一 operation 的 event/outbox 数量一一对应,失败演练不留下孤立 event、孤立 outbox 或部分身份状态。 +5. **P0-TXN-5 安全边界与证据(完成)**:真实 PostgreSQL 专项 `1/1` 通过,结束后 `privileged_governance_%` schema 残留 `0`。仅新增测试,不增加迁移、运行时 executor、execute/rollback/compensate API、transport、support access 或人工账本入口;凭据未落盘。 + +验收口径:隔离演练允许在随机测试 schema 内提交和回滚测试身份投影,但不代表真实身份执行获准上线;计划 hash 不替代锁内二次 preflight;任何故障必须让身份、event 和 outbox 同时回滚。下一阶段按顺序推进 support access 在线鉴权、自动到期、紧急撤销和独立安全评审,通用代入、共享密码/会话及真实支持访问继续禁止。 + +### 9.41 第二十二阶段:support access validation-only 安全策略(已交付,2026-08-09) + +1. **P0-SUPPORT-1 审批与范围绑定(完成)**:只接受 approved、两名有效审批和 preview hash 当前有效的材料;账号、工作空间、固定 allowlist scope、revision、起止时间和 operation ID 绑定确定性 policy hash,原始 subject reference 仅保留指纹。 +2. **P0-SUPPORT-2 在线状态检查(完成)**:每次检查账号/工作空间匹配、scope、当前 active 状态和 current revision;主体不匹配、停用或 revision 漂移均返回稳定阻断码。 +3. **P0-SUPPORT-3 自动到期(完成)**:不依赖后台物化,`checked_at >= expires_at` 立即失效;TTL 强制不超过 24 小时,所有时间必须带时区。 +4. **P0-SUPPORT-4 紧急撤销(完成)**:固定原因码和 revocation hash 绑定撤销材料;相同撤销幂等,不同撤销重写拒绝,在线检查先验证 policy/revocation hash 再判定 revoked。 +5. **P0-SUPPORT-5 执行器继续关闭(完成)**:所有结果固定 `access_granted=false`,模块没有 activation flag、token/session/impersonation 或 API 绑定。专项 `15/15`、与计划层组合 `24/24`、compileall 和 diff-check 通过;无迁移、路由或持久化 grant/revoke 写入口。 + +验收口径:`policy_satisfied=true` 只表示 validation-only 材料在检查时刻满足策略,不是访问授权;任何调用方不得把该字段映射为放行。下一步必须完成独立安全评审和真实 enforcement 设计,至少覆盖 TOCTOU、可信时钟、撤销传播、持久化、强认证、会话绑定、审计与最小 scope;评审通过前真实 support access 继续关闭。 + +### 9.42 第二十三阶段:特权身份与 support access 独立安全评审(已完成,No-Go,2026-08-09) + +评审报告:`V2-privileged-identity-security-review-2026-08-09.md`。当前实现获准继续作为 validation-only 和隔离演练基础,但真实身份执行与 support access 发布被拒绝。 + +P0 后续 backlog:建立 durable grant/revocation 与跨节点一致性;绑定可信数据库/服务端时间;在统一 deny-by-default enforcement point 内闭合 TOCTOU;实现 step-up/MFA、设备/会话和短时凭证绑定;演练撤销传播 SLA 与 kill switch;建立逐请求不可变审计/outbox、异常告警和隐私/法律审批。上述全部验收前不得增加 support access、execute、rollback、grant、revoke、impersonation、共享密码/会话或人工绕过入口。 diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/privileged-operations/page-content.test.mjs b/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/privileged-operations/page-content.test.mjs index 2d94fae0..10724ead 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/privileged-operations/page-content.test.mjs +++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/privileged-operations/page-content.test.mjs @@ -7,6 +7,10 @@ const clientSource = await readFile( new URL("../../../../services/contracts-client.ts", import.meta.url), "utf8", ); +const contractsSource = await readFile( + new URL("../../../../services/contracts.ts", import.meta.url), + "utf8", +); test("the governance page exposes preview, records, and approval without execution", () => { assert.match(pageSource, /from "@\/services\/contracts-client"/); @@ -16,6 +20,7 @@ test("the governance page exposes preview, records, and approval without executi assert.match(pageSource, /decidePrivilegedIdentityOperation/); assert.match(pageSource, /getPrivilegedIdentityOperationEvents/); assert.match(pageSource, /getPrivilegedIdentityOperationDeliveryStatus/); + assert.match(pageSource, /getPrivilegedIdentityExecutionPreflight/); assert.match(pageSource, /事件投递/); assert.match(pageSource, /投递状态/); assert.match(pageSource, /状态事件时间线/); @@ -28,6 +33,91 @@ test("the governance page exposes preview, records, and approval without executi assert.doesNotMatch(pageSource, /retryPrivileged|\/retry/); }); +test("execution preflight is GET-only and renders readiness, revisions, and the fixed lock plan", () => { + const preflightFunction = clientSource.match( + /export async function getPrivilegedIdentityExecutionPreflight\([\s\S]*?\n\}/, + )?.[0]; + assert.ok(preflightFunction); + assert.match(preflightFunction, /privileged-operations\/\$\{encodeURIComponent\(operationId\)\}\/execution-preflight/); + assert.match(preflightFunction, /fetchClientJson/); + assert.doesNotMatch(preflightFunction, /identityClientJsonRequest|method:\s*["'](?:POST|PUT|PATCH|DELETE)["']/); + + assert.match(contractsSource, /export type PrivilegedIdentityExecutionPreflight/); + assert.match(contractsSource, /PrivilegedIdentityPreflightBlocker/); + assert.match(contractsSource, /PrivilegedIdentityPreflightRevision/); + assert.match(contractsSource, /PrivilegedIdentityPreflightLock/); + assert.match(pageSource, /executionPreflight\.ready/); + assert.match(pageSource, /executionPreflight\.blockers/); + assert.match(pageSource, /executionPreflight\.revisions/); + assert.match(pageSource, /executionPreflight\.lock_plan/); + assert.match(pageSource, /executionPreflight\.locks_acquired/); + assert.match(pageSource, /executionPreflight\.executor_enabled/); + assert.match(pageSource, /未加锁/); + assert.match(pageSource, /未启用/); + assert.match(pageSource, /固定锁计划/); + assert.doesNotMatch(`${pageSource}\n${preflightFunction}`, /\/execute(?:[?/'"`]|$)/); +}); + +test("execution attempt history is GET-only, minimally disclosed, and has no action controls", () => { + const attemptsFunction = clientSource.match( + /export async function getPrivilegedIdentityExecutionAttempts\([\s\S]*?\n\}/, + )?.[0]; + assert.ok(attemptsFunction); + assert.match( + attemptsFunction, + /privileged-operations\/\$\{encodeURIComponent\(operationId\)\}\/execution-attempts/, + ); + assert.match(attemptsFunction, /fetchClientJson/); + assert.doesNotMatch( + attemptsFunction, + /identityClientJsonRequest|method:\s*["'](?:POST|PUT|PATCH|DELETE)["']/, + ); + + assert.match(contractsSource, /export type PrivilegedIdentityExecutionAttemptStatus/); + assert.match(contractsSource, /export type PrivilegedIdentityExecutionAttemptPage/); + for (const field of [ + "attempt_id", + "attempt_no", + "status", + "event_count", + "last_error_code", + "trace_id", + "created_at", + "latest_event_at", + ]) { + assert.match(contractsSource, new RegExp(`${field}\\??:`)); + } + assert.match(pageSource, /执行尝试历史/); + assert.match(pageSource, /executionAttempts\?\.items\.map/); + assert.match(pageSource, /attempt\.event_count/); + assert.match(pageSource, /attempt\.last_error_code/); + assert.match(pageSource, /attempt\.trace_id/); + assert.match(pageSource, /只读 · 执行器关闭/); + assert.doesNotMatch( + pageSource, + /attempt\.(?:reason|ticket_reference|target_refs|evidence_refs|actor|comment|email|payload|credentials)/, + ); + assert.doesNotMatch( + `${pageSource}\n${attemptsFunction}`, + /retryExecutionAttempt|executePrivileged|compensatePrivileged|abortExecutionAttempt|\/execution-attempts\/[^\s]+\/(?:retry|execute|compensate|abort)/, + ); +}); + +test("the rendered governance controls do not expose destructive identity actions", () => { + const buttonMarkup = [...pageSource.matchAll(//g)] + .map((match) => match[0]) + .join("\n"); + assert.ok(buttonMarkup); + assert.doesNotMatch( + buttonMarkup, + /执行身份变更|开始执行|立即执行|重新执行|重试执行|执行回滚|开始回滚|执行补偿|开始补偿|中止执行|身份写入/, + ); + assert.doesNotMatch( + clientSource, + /export async function (?:execute|rollback|retry|compensate|abort|merge|split|recover|grant|revoke)[A-Z]/, + ); +}); + test("the governance page exposes read-only global outbox observability", () => { assert.match(pageSource, /getPrivilegedIdentityOutboxOverview/); assert.match(pageSource, /getPrivilegedIdentityOutboxDeadLetters/); @@ -47,6 +137,58 @@ test("the governance page exposes read-only global outbox observability", () => assert.doesNotMatch(pageSource, /retryPrivileged|requeuePrivileged|ackPrivileged|\/retry|\/requeue|\/ack/); }); +test("approval SLA overview and queue are GET-only and minimally disclosed", () => { + const overviewFunction = clientSource.match( + /export async function getPrivilegedIdentityApprovalSlaOverview\([\s\S]*?\n\}/, + )?.[0]; + const queueFunction = clientSource.match( + /export async function getPrivilegedIdentityApprovalSlaQueue\([\s\S]*?\n\}/, + )?.[0]; + assert.ok(overviewFunction); + assert.ok(queueFunction); + assert.match(overviewFunction, /privileged-approval-sla\/overview/); + assert.match(queueFunction, /privileged-approval-sla\/queue/); + assert.match(queueFunction, /page_size/); + assert.match(queueFunction, /sla_status/); + assert.match(queueFunction, /scope/); + assert.match(contractsSource, /PrivilegedIdentityApprovalSlaOverview/); + assert.match(contractsSource, /PrivilegedIdentityApprovalSlaQueuePage/); + assert.match(pageSource, /审批时效队列/); + assert.match(pageSource, /approvalSlaOverview\.counts\.breached/); + assert.match(pageSource, /approvalSlaQueue\.items/); + assert.match(pageSource, /只读队列/); + assert.doesNotMatch(`${overviewFunction}\n${queueFunction}`, /identityClientJsonRequest|method:\s*["'](?:POST|PUT|PATCH|DELETE)["']/); + assert.doesNotMatch(`${pageSource}\n${overviewFunction}\n${queueFunction}`, /approvalSla.*(?:retry|execute|approve|reject)/i); +}); + +test("approval SLA exposes cross-window backlog through an accessible read-only scope", () => { + const queueFunction = clientSource.match( + /export async function getPrivilegedIdentityApprovalSlaQueue\([\s\S]*?\n\}/, + )?.[0]; + assert.ok(queueFunction); + assert.match(contractsSource, /export type PrivilegedIdentityApprovalSlaScope = "window" \| "all"/); + assert.match(contractsSource, /all_pending_total: number/); + assert.match(contractsSource, /outside_window_pending_count: number/); + assert.match(contractsSource, /outside_window_breached_count: number/); + assert.match(contractsSource, /oldest_pending_age_seconds\?: number \| null/); + assert.match(contractsSource, /scope: PrivilegedIdentityApprovalSlaScope/); + assert.match(queueFunction, /scope: PrivilegedIdentityApprovalSlaScope = "window"/); + assert.match(queueFunction, /scope,/); + assert.match(pageSource, /aria-label="审批队列时间范围"/); + assert.match(pageSource, /aria-pressed=\{approvalSlaScope === "window"\}/); + assert.match(pageSource, /aria-pressed=\{approvalSlaScope === "all"\}/); + assert.match(pageSource, /最近 7 天/); + assert.match(pageSource, /全部积压/); + assert.match(pageSource, /观察窗口外仍有/); + assert.match(pageSource, /oldest_pending_age_seconds/); + assert.match(pageSource, /approvalSlaQueue\.scope === "all"/); + assert.doesNotMatch( + pageSource, + /approvalSla(?:Overview|Queue).*\.(?:reason|ticket_reference|target_refs|evidence_refs|actor|comment|email|payload|credentials)/, + ); + assert.doesNotMatch(queueFunction, /identityClientJsonRequest|method:\s*["'](?:POST|PUT|PATCH|DELETE)["']/); +}); + test("outbox observability client calls are GET-only and keep filters in the query string", () => { const overviewFunction = clientSource.match( /export async function getPrivilegedIdentityOutboxOverview\([\s\S]*?\n\}/, diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/privileged-operations/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/privileged-operations/page.tsx index eaa2a6cb..497fe6cf 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/privileged-operations/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/privileged-operations/page.tsx @@ -12,11 +12,16 @@ import { LockKeyhole, RefreshCw, ShieldCheck, + Timer, X, } from "lucide-react"; import { createPrivilegedIdentityOperation, decidePrivilegedIdentityOperation, + getPrivilegedIdentityApprovalSlaOverview, + getPrivilegedIdentityApprovalSlaQueue, + getPrivilegedIdentityExecutionAttempts, + getPrivilegedIdentityExecutionPreflight, getPrivilegedIdentityOperation, getPrivilegedIdentityOperationDeliveryStatus, getPrivilegedIdentityOperationEvents, @@ -27,6 +32,13 @@ import { } from "@/services/contracts-client"; import type { PrivilegedIdentityDeliveryStatus, + PrivilegedIdentityExecutionAttemptPage, + PrivilegedIdentityExecutionAttemptStatus, + PrivilegedIdentityExecutionPreflight, + PrivilegedIdentityApprovalSlaOverview, + PrivilegedIdentityApprovalSlaQueuePage, + PrivilegedIdentityApprovalSlaScope, + PrivilegedIdentityApprovalSlaStatus, PrivilegedIdentityOutboxDeadLetterPage, PrivilegedIdentityOutboxOverview, PrivilegedIdentityOperation, @@ -45,6 +57,9 @@ import { const operationTypes = Object.keys(privilegedOperationDefinitions) as PrivilegedIdentityOperationType[]; const outboxStaleAfterSeconds = 900; const deadLetterPageSize = 20; +const approvalSlaDueSoonSeconds = 3600; +const approvalSlaWindowDays = 7; +const approvalSlaPageSize = 10; export default function PrivilegedIdentityOperationsPage() { const [operationType, setOperationType] = useState("account_merge"); @@ -57,6 +72,12 @@ export default function PrivilegedIdentityOperationsPage() { const [requestedExpiresAt, setRequestedExpiresAt] = useState(""); const [operations, setOperations] = useState([]); const [selected, setSelected] = useState(null); + const [executionPreflight, setExecutionPreflight] = useState(null); + const [preflightLoading, setPreflightLoading] = useState(false); + const [preflightUnavailable, setPreflightUnavailable] = useState(false); + const [executionAttempts, setExecutionAttempts] = useState(null); + const [attemptsLoading, setAttemptsLoading] = useState(false); + const [attemptsUnavailable, setAttemptsUnavailable] = useState(false); const [events, setEvents] = useState([]); const [deliveryStatus, setDeliveryStatus] = useState([]); const [eventsLoading, setEventsLoading] = useState(false); @@ -71,6 +92,12 @@ export default function PrivilegedIdentityOperationsPage() { const [deadLettersLoading, setDeadLettersLoading] = useState(true); const [deadLettersUnavailable, setDeadLettersUnavailable] = useState(false); const [deadLetterErrorCode, setDeadLetterErrorCode] = useState(""); + const [approvalSlaOverview, setApprovalSlaOverview] = useState(null); + const [approvalSlaQueue, setApprovalSlaQueue] = useState(null); + const [approvalSlaLoading, setApprovalSlaLoading] = useState(true); + const [approvalSlaUnavailable, setApprovalSlaUnavailable] = useState(false); + const [approvalSlaStatus, setApprovalSlaStatus] = useState(""); + const [approvalSlaScope, setApprovalSlaScope] = useState("window"); const definition = privilegedOperationDefinitions[operationType]; const targetRefs = useMemo( @@ -82,14 +109,53 @@ export default function PrivilegedIdentityOperationsPage() { void refreshGovernanceData(); }, []); - async function refreshGovernanceData() { + async function refreshGovernanceData(selectedOperationId = selected?.operation_id) { await Promise.all([ - refreshOperations(), + refreshOperations(selectedOperationId), refreshOutboxOverview(), refreshDeadLetters(deadLetters?.page ?? 1, deadLetterErrorCode), + refreshApprovalSla(approvalSlaQueue?.page ?? 1, approvalSlaStatus, approvalSlaScope), ]); } + async function refreshApprovalSla( + page: number, + status: PrivilegedIdentityApprovalSlaStatus | "", + scope: PrivilegedIdentityApprovalSlaScope, + ) { + setApprovalSlaLoading(true); + try { + const [overview, queue] = await Promise.all([ + getPrivilegedIdentityApprovalSlaOverview(approvalSlaDueSoonSeconds, approvalSlaWindowDays), + getPrivilegedIdentityApprovalSlaQueue( + page, + approvalSlaPageSize, + approvalSlaDueSoonSeconds, + approvalSlaWindowDays, + status || undefined, + scope, + ), + ]); + setApprovalSlaOverview(overview); + setApprovalSlaQueue(queue); + setApprovalSlaUnavailable(overview === null || queue === null); + } finally { + setApprovalSlaLoading(false); + } + } + + function filterApprovalSla(value: string) { + const status = value as PrivilegedIdentityApprovalSlaStatus | ""; + setApprovalSlaStatus(status); + void refreshApprovalSla(1, status, approvalSlaScope); + } + + function changeApprovalSlaScope(scope: PrivilegedIdentityApprovalSlaScope) { + if (scope === approvalSlaScope) return; + setApprovalSlaScope(scope); + void refreshApprovalSla(1, approvalSlaStatus, scope); + } + async function refreshOutboxOverview() { setOverviewLoading(true); try { @@ -121,22 +187,34 @@ export default function PrivilegedIdentityOperationsPage() { void refreshDeadLetters(1, errorCode); } - async function refreshOperations() { + async function refreshOperations(selectedOperationId?: string) { setLoading(true); + if (selectedOperationId) { + setPreflightLoading(true); + setAttemptsLoading(true); + } try { const page = await getPrivilegedIdentityOperations(); setOperations(page?.items ?? []); - if (selected) { - const [refreshed, eventPage, deliveryPage] = await Promise.all([ - getPrivilegedIdentityOperation(selected.operation_id), - getPrivilegedIdentityOperationEvents(selected.operation_id), - getPrivilegedIdentityOperationDeliveryStatus(selected.operation_id), + if (selectedOperationId) { + const [refreshed, eventPage, deliveryPage, preflightResult, attemptPage] = await Promise.all([ + getPrivilegedIdentityOperation(selectedOperationId), + getPrivilegedIdentityOperationEvents(selectedOperationId), + getPrivilegedIdentityOperationDeliveryStatus(selectedOperationId), + getPrivilegedIdentityExecutionPreflight(selectedOperationId), + getPrivilegedIdentityExecutionAttempts(selectedOperationId), ]); setSelected(refreshed); setEvents(eventPage?.items ?? []); setDeliveryStatus(deliveryPage?.items ?? []); + setExecutionPreflight(preflightResult); + setPreflightUnavailable(preflightResult === null); + setExecutionAttempts(attemptPage); + setAttemptsUnavailable(attemptPage === null); } } finally { + setPreflightLoading(false); + setAttemptsLoading(false); setLoading(false); } } @@ -193,19 +271,16 @@ export default function PrivilegedIdentityOperationsPage() { evidence_refs: preview.evidence_refs ?? [], }); setSelected(created); - const eventPage = await getPrivilegedIdentityOperationEvents(created.operation_id); - setEvents(eventPage?.items ?? []); - const deliveryPage = await getPrivilegedIdentityOperationDeliveryStatus(created.operation_id); - setDeliveryStatus(deliveryPage?.items ?? []); setPreview(null); setReason(""); setTicketReference(""); setRequestedExpiresAt(""); setMessage("治理操作已登记,等待两名独立平台管理员审批。"); - await refreshGovernanceData(); + await refreshGovernanceData(created.operation_id); } catch (error) { setMessage(error instanceof Error ? error.message : "治理操作登记失败。"); } finally { + setPreflightLoading(false); setBusy(null); } } @@ -213,18 +288,28 @@ export default function PrivilegedIdentityOperationsPage() { async function selectOperation(operation: PrivilegedIdentityOperation) { setBusy(`load:${operation.operation_id}`); setEventsLoading(true); + setPreflightLoading(true); + setAttemptsLoading(true); try { - const [detail, eventPage, deliveryPage] = await Promise.all([ + const [detail, eventPage, deliveryPage, preflightResult, attemptPage] = await Promise.all([ getPrivilegedIdentityOperation(operation.operation_id), getPrivilegedIdentityOperationEvents(operation.operation_id), getPrivilegedIdentityOperationDeliveryStatus(operation.operation_id), + getPrivilegedIdentityExecutionPreflight(operation.operation_id), + getPrivilegedIdentityExecutionAttempts(operation.operation_id), ]); setSelected(detail); setEvents(eventPage?.items ?? []); setDeliveryStatus(deliveryPage?.items ?? []); + setExecutionPreflight(preflightResult); + setPreflightUnavailable(preflightResult === null); + setExecutionAttempts(attemptPage); + setAttemptsUnavailable(attemptPage === null); setApprovalComment(""); } finally { setEventsLoading(false); + setPreflightLoading(false); + setAttemptsLoading(false); setBusy(null); } } @@ -242,10 +327,11 @@ export default function PrivilegedIdentityOperationsPage() { setSelected(updated); setApprovalComment(""); setMessage(decision === "approve" ? "审批意见已记录。" : "拒绝意见已记录。"); - await refreshGovernanceData(); + await refreshGovernanceData(updated.operation_id); } catch (error) { setMessage(error instanceof Error ? error.message : "审批提交失败。"); } finally { + setPreflightLoading(false); setBusy(null); } } @@ -269,6 +355,95 @@ export default function PrivilegedIdentityOperationsPage() { {message &&
{message}
} +
+
+
+

Approval SLA

+

审批时效队列

+
+ + {approvalSlaLoading ? ( + 读取中 + ) : approvalSlaOverview ? ( + 0 || approvalSlaOverview.outside_window_breached_count > 0 ? "tag-danger" : approvalSlaOverview.counts.due_soon > 0 ? "tag-warning" : "tag-success"}`}> + {approvalSlaOverview.counts.breached > 0 || approvalSlaOverview.outside_window_breached_count > 0 ? "有超期" : approvalSlaOverview.counts.due_soon > 0 ? "有临期" : "正常"} + + ) : ( + 不可用 + )} + + +
+
+ {approvalSlaLoading &&
正在读取审批时效...
} + {!approvalSlaLoading && approvalSlaUnavailable && ( +
暂时无法读取审批时效队列。
+ )} + {!approvalSlaLoading && !approvalSlaUnavailable && approvalSlaOverview && approvalSlaQueue && ( + <> +
+ 观察窗口最近 {approvalSlaOverview.window_days} 天创建的待审批操作 · 临期阈值 {formatAge(approvalSlaOverview.due_soon_seconds)} + 窗口内 {approvalSlaOverview.total} 条 · 全部 {approvalSlaOverview.all_pending_total} 条 +
+
+
正常{approvalSlaOverview.counts.on_track}
+
临期{approvalSlaOverview.counts.due_soon}
+
超期{approvalSlaOverview.counts.breached}
+
+ {approvalSlaOverview.outside_window_pending_count > 0 && ( +
+ 观察窗口外仍有 {approvalSlaOverview.outside_window_pending_count} 条待审批积压 + + 其中 {approvalSlaOverview.outside_window_breached_count} 条已超期 + {approvalSlaOverview.oldest_pending_age_seconds !== null && approvalSlaOverview.oldest_pending_age_seconds !== undefined + ? ` · 最老待审批已等待 ${formatAge(approvalSlaOverview.oldest_pending_age_seconds)}` + : ""} + +
+ )} +
+ + + + + + + + 只读队列,不改变审批状态 +
+ {approvalSlaQueue.items.length === 0 &&
{approvalSlaQueue.scope === "all" ? "当前没有待审批积压。" : "当前窗口没有待审批队列项。"}
} + {approvalSlaQueue.items.map((item) => ( +
+ + {privilegedOperationDefinitions[item.operation_type]?.label ?? item.operation_type} + {item.operation_id} · 有效审批 {item.valid_approval_count}/{item.required_approval_count} · 还需 {item.remaining_approval_count} + 到期 {formatDate(item.expires_at)} · {formatSlaRemaining(item.seconds_until_due)} + + {approvalSlaLabel(item.sla_status)} +
+ ))} + {approvalSlaQueue.total > 0 && ( +
+ 第 {approvalSlaQueue.page} / {Math.max(approvalSlaQueue.total_pages, 1)} 页,共 {approvalSlaQueue.total} 条 + + + + +
+ )} + + )} +
+
+
@@ -593,6 +768,124 @@ export default function PrivilegedIdentityOperationsPage() {
))}
+
+
+
+

Read-only execution preflight

+

统一执行器只读预检

+
+ +
+ {preflightLoading &&
正在复核执行条件...
} + {!preflightLoading && preflightUnavailable && ( +
暂时无法读取执行预检。
+ )} + {!preflightLoading && !preflightUnavailable && executionPreflight && ( + <> +
+ + 检查结论 + 生成于 {formatDate(executionPreflight.generated_at)} + + + {executionPreflight.ready ? "条件就绪" : `${executionPreflight.blockers.length} 项阻断`} + +
+
+ 存储状态 / 有效状态 + + {privilegedOperationStatusLabels[executionPreflight.stored_status]} + {" / "} + {privilegedOperationStatusLabels[executionPreflight.effective_status]} + +
+
有效审批{executionPreflight.valid_approval_count} / {executionPreflight.required_approval_count}
+
预览哈希复核{executionPreflight.preview_hash_verified ? "通过" : "未通过"}
+
审批有效期{formatDate(executionPreflight.expires_at)}
+
原始预览有效期{executionPreflight.preview_expires_at ? formatDate(executionPreflight.preview_expires_at) : "历史记录未保存"}
+
+ 数据库锁{executionPreflight.locks_acquired ? "已获取" : "未获取"} + {executionPreflight.locks_acquired ? "已加锁" : "未加锁"} +
+
+ 身份执行器{executionPreflight.executor_enabled ? "启用" : "关闭"} + {executionPreflight.executor_enabled ? "已启用" : "未启用"} +
+ {executionPreflight.blockers.length > 0 && ( +
+
阻断项稳定错误码与检查详情
+
+ )} + {executionPreflight.blockers.map((blocker) => ( +
+ {blocker.code}{blocker.detail} + 阻断 +
+ ))} +
+
Revision 检查审批快照与当前身份实体版本对比
+
+ {executionPreflight.revisions.length === 0 &&
无实体 revision 检查项。
} + {executionPreflight.revisions.map((revision) => ( +
+ + {revision.entity_type} · {revision.entity_id} + expected: {revision.expected_revision ?? "未提供"} + current: {revision.current_revision ?? "不存在"} + + {revision.matches ? "一致" : "漂移"} +
+ ))} +
+
固定锁计划按 sequence 排序;同类资源按 ID 固定排序
+
+ {executionPreflight.lock_plan.map((lock) => ( +
+ + #{lock.sequence} · {lock.resource_type} + {lock.resource_id} + {lock.lock_mode} · expected: {lock.expected_revision ?? "未提供"} · current: {lock.current_revision ?? "不存在"} + + {lock.matches ? "一致" : "漂移"} +
+ ))} + + )} +
+
+
+
+

Read-only execution history

+

执行尝试历史

+
+ {executionAttempts && ( + + {executionAttempts.executor_enabled ? "执行器已开启" : "只读 · 执行器关闭"} + + )} +
+ {attemptsLoading &&
正在读取执行尝试历史...
} + {!attemptsLoading && attemptsUnavailable && ( +
暂时无法读取执行尝试历史。
+ )} + {!attemptsLoading && !attemptsUnavailable && executionAttempts?.items.length === 0 && ( +
尚无执行尝试记录。
+ )} + {!attemptsLoading && !attemptsUnavailable && executionAttempts?.items.map((attempt) => ( +
+ + 第 {attempt.attempt_no} 次 · {executionAttemptStatusLabel(attempt.status)} + {attempt.attempt_id} · {attempt.event_count} 个追加式事件 + 创建于 {formatDate(attempt.created_at)} · 最近事件 {formatDate(attempt.latest_event_at)} + trace_id: {attempt.trace_id || "未提供"} + {attempt.last_error_code && 错误码: {attempt.last_error_code}} + + + {executionAttemptStatusLabel(attempt.status)} + +
+ ))} +
@@ -754,6 +1047,46 @@ function formatAge(value?: number | null) { return `${(seconds / 3600).toFixed(seconds >= 36000 ? 0 : 1)} 小时`; } +function approvalSlaLabel(status: PrivilegedIdentityApprovalSlaStatus) { + return { + on_track: "正常", + due_soon: "临期", + breached: "超期", + }[status]; +} + +function approvalSlaTone(status: PrivilegedIdentityApprovalSlaStatus) { + if (status === "breached") return "tag-danger"; + if (status === "due_soon") return "tag-warning"; + return "tag-success"; +} + +function formatSlaRemaining(secondsUntilDue: number) { + if (secondsUntilDue < 0) return `已超期 ${formatAge(Math.abs(secondsUntilDue))}`; + if (secondsUntilDue === 0) return "现在到期"; + return `剩余 ${formatAge(secondsUntilDue)}`; +} + +function executionAttemptStatusLabel(status: PrivilegedIdentityExecutionAttemptStatus) { + return { + prepared: "已准备", + running: "运行中", + succeeded: "已成功", + failed: "已失败", + compensation_required: "待补偿", + compensated: "已补偿", + compensation_failed: "补偿失败", + aborted: "已中止", + }[status]; +} + +function executionAttemptStatusTone(status: PrivilegedIdentityExecutionAttemptStatus) { + if (status === "succeeded" || status === "compensated") return "tag-success"; + if (status === "prepared" || status === "running") return "tag-primary"; + if (status === "aborted") return "tag-warning"; + return "tag-danger"; +} + function deadLetterEventLabel(eventType: string) { const labels: Record = { operation_submitted: "登记操作", diff --git a/yuqei-contract-platform/apps/contract-web/src/app/globals.css b/yuqei-contract-platform/apps/contract-web/src/app/globals.css index 61691ef8..4f864021 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/globals.css +++ b/yuqei-contract-platform/apps/contract-web/src/app/globals.css @@ -2159,6 +2159,63 @@ textarea { overflow-wrap: anywhere; } +.identity-sla-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + min-width: 0; +} + +.identity-sla-scope { + display: inline-flex; + gap: 2px; + padding: 3px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-sunken); +} + +.identity-sla-scope .button { + min-height: 30px; + border: 0; + border-radius: 6px; + box-shadow: none; +} + +.identity-sla-scope .button:hover { + transform: none; + box-shadow: none; +} + +.identity-sla-scope .identity-sla-scope-button-active, +.identity-sla-scope .identity-sla-scope-button-active:hover { + color: #fff; + background: var(--primary); +} + +.identity-inline-filter { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 13px; +} + +.identity-inline-filter select { + min-height: 32px; + padding: 0 30px 0 10px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--ink); + background: var(--surface-solid); +} + +.identity-approval-backlog-warning { + align-items: flex-start; + text-align: left; +} + .identity-selected { border-color: var(--line-strong); background: var(--surface-tinted); diff --git a/yuqei-contract-platform/apps/contract-web/src/services/contracts-client.ts b/yuqei-contract-platform/apps/contract-web/src/services/contracts-client.ts index 28ad8253..077b4fb4 100644 --- a/yuqei-contract-platform/apps/contract-web/src/services/contracts-client.ts +++ b/yuqei-contract-platform/apps/contract-web/src/services/contracts-client.ts @@ -6,6 +6,12 @@ import type { PrivilegedIdentityDeliveryStatusPage, PrivilegedIdentityOutboxDeadLetterPage, PrivilegedIdentityOutboxOverview, + PrivilegedIdentityExecutionAttemptPage, + PrivilegedIdentityExecutionPreflight, + PrivilegedIdentityApprovalSlaOverview, + PrivilegedIdentityApprovalSlaQueuePage, + PrivilegedIdentityApprovalSlaScope, + PrivilegedIdentityApprovalSlaStatus, PrivilegedIdentityOperation, PrivilegedIdentityOperationCreateRequest, PrivilegedIdentityOperationEventPage, @@ -78,6 +84,52 @@ export async function getPrivilegedIdentityOperation( ); } +export async function getPrivilegedIdentityExecutionPreflight( + operationId: string, +): Promise { + return fetchClientJson( + `/api/contract-platform/identity/privileged-operations/${encodeURIComponent(operationId)}/execution-preflight`, + ); +} + +export async function getPrivilegedIdentityExecutionAttempts( + operationId: string, +): Promise { + return fetchClientJson( + `/api/contract-platform/identity/privileged-operations/${encodeURIComponent(operationId)}/execution-attempts`, + ); +} + +export async function getPrivilegedIdentityApprovalSlaOverview( + dueSoonSeconds = 3600, + windowDays = 7, +): Promise { + return fetchClientJson( + `/api/contract-platform/identity/privileged-approval-sla/overview?due_soon_seconds=${encodeURIComponent(String(dueSoonSeconds))}&window_days=${encodeURIComponent(String(windowDays))}`, + ); +} + +export async function getPrivilegedIdentityApprovalSlaQueue( + page = 1, + pageSize = 20, + dueSoonSeconds = 3600, + windowDays = 7, + slaStatus?: PrivilegedIdentityApprovalSlaStatus, + scope: PrivilegedIdentityApprovalSlaScope = "window", +): Promise { + const searchParams = new URLSearchParams({ + page: String(page), + page_size: String(pageSize), + due_soon_seconds: String(dueSoonSeconds), + window_days: String(windowDays), + scope, + }); + if (slaStatus) searchParams.set("sla_status", slaStatus); + return fetchClientJson( + `/api/contract-platform/identity/privileged-approval-sla/queue?${searchParams.toString()}`, + ); +} + export async function getPrivilegedIdentityOperationEvents( operationId: string, ): Promise { diff --git a/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts b/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts index b436593e..3232bc4a 100644 --- a/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts +++ b/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts @@ -615,6 +615,124 @@ export type PrivilegedIdentityOperationPage = { page_size: number; }; +export type PrivilegedIdentityPreflightBlocker = { + code: string; + detail: string; +}; + +export type PrivilegedIdentityPreflightRevision = { + entity_type: string; + entity_id: string; + expected_revision?: string | null; + current_revision?: string | null; + matches: boolean; +}; + +export type PrivilegedIdentityPreflightLock = { + sequence: number; + resource_type: string; + resource_id: string; + lock_mode: "for_update"; + expected_revision?: string | null; + current_revision?: string | null; + matches: boolean; +}; + +export type PrivilegedIdentityExecutionPreflight = { + operation_id: string; + operation_type: PrivilegedIdentityOperationType; + stored_status: PrivilegedIdentityOperationStatus; + effective_status: PrivilegedIdentityOperationStatus; + generated_at: string; + expires_at: string; + preview_expires_at?: string | null; + preview_hash_verified: boolean; + valid_approval_count: number; + required_approval_count: number; + ready: boolean; + blockers: PrivilegedIdentityPreflightBlocker[]; + revisions: PrivilegedIdentityPreflightRevision[]; + lock_plan: PrivilegedIdentityPreflightLock[]; + locks_acquired: boolean; + executor_enabled: boolean; +}; + +export type PrivilegedIdentityExecutionAttemptStatus = + | "prepared" + | "running" + | "succeeded" + | "failed" + | "compensation_required" + | "compensated" + | "compensation_failed" + | "aborted"; + +export type PrivilegedIdentityExecutionAttempt = { + attempt_id: string; + operation_id: string; + attempt_no: number; + status: PrivilegedIdentityExecutionAttemptStatus; + event_count: number; + last_error_code?: string | null; + trace_id?: string | null; + created_at: string; + latest_event_at: string; +}; + +export type PrivilegedIdentityExecutionAttemptPage = { + items: PrivilegedIdentityExecutionAttempt[]; + total: number; + page: number; + page_size: number; + executor_enabled: boolean; +}; + +export type PrivilegedIdentityApprovalSlaStatus = "on_track" | "due_soon" | "breached"; + +export type PrivilegedIdentityApprovalSlaScope = "window" | "all"; + +export type PrivilegedIdentityApprovalSlaCounts = { + on_track: number; + due_soon: number; + breached: number; +}; + +export type PrivilegedIdentityApprovalSlaOverview = { + generated_at: string; + due_soon_seconds: number; + window_days: number; + total: number; + counts: PrivilegedIdentityApprovalSlaCounts; + all_pending_total: number; + outside_window_pending_count: number; + outside_window_breached_count: number; + oldest_pending_age_seconds?: number | null; +}; + +export type PrivilegedIdentityApprovalSlaQueueItem = { + operation_id: string; + operation_type: PrivilegedIdentityOperationType; + sla_status: PrivilegedIdentityApprovalSlaStatus; + valid_approval_count: number; + required_approval_count: number; + remaining_approval_count: number; + created_at: string; + expires_at: string; + seconds_until_due: number; +}; + +export type PrivilegedIdentityApprovalSlaQueuePage = { + items: PrivilegedIdentityApprovalSlaQueueItem[]; + total: number; + page: number; + page_size: number; + total_pages: number; + generated_at: string; + due_soon_seconds: number; + window_days: number; + scope: PrivilegedIdentityApprovalSlaScope; +}; + export type PrivilegedIdentityOperationCreateRequest = { operation_id: string; preview_hash: string; diff --git a/yuqei-contract-platform/services/contract-api/migrations/versions/20260803_0042_add_privileged_identity_preview_expiry.py b/yuqei-contract-platform/services/contract-api/migrations/versions/20260803_0042_add_privileged_identity_preview_expiry.py new file mode 100644 index 00000000..a8c36eab --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/migrations/versions/20260803_0042_add_privileged_identity_preview_expiry.py @@ -0,0 +1,30 @@ +"""store privileged identity preview expiry for execution preflight + +Revision ID: 20260803_0042 +Revises: 20260803_0041 +Create Date: 2026-08-03 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260803_0042" +down_revision: Union[str, None] = "20260803_0041" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "privileged_identity_operations", + sa.Column("preview_expires_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("privileged_identity_operations", "preview_expires_at") diff --git a/yuqei-contract-platform/services/contract-api/migrations/versions/20260803_0043_add_privileged_identity_execution_attempts.py b/yuqei-contract-platform/services/contract-api/migrations/versions/20260803_0043_add_privileged_identity_execution_attempts.py new file mode 100644 index 00000000..1fd29b32 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/migrations/versions/20260803_0043_add_privileged_identity_execution_attempts.py @@ -0,0 +1,220 @@ +"""add append-only privileged identity execution attempt ledger + +Revision ID: 20260803_0043 +Revises: 20260803_0042 +Create Date: 2026-08-03 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260803_0043" +down_revision: Union[str, None] = "20260803_0042" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +_ATTEMPT_TABLE = "privileged_identity_execution_attempts" +_EVENT_TABLE = "privileged_identity_execution_attempt_events" + + +def upgrade() -> None: + op.create_table( + _ATTEMPT_TABLE, + sa.Column("attempt_id", sa.String(length=64), nullable=False), + sa.Column("operation_id", sa.String(length=64), nullable=False), + sa.Column("attempt_no", sa.Integer(), nullable=False), + sa.Column("idempotency_key", sa.String(length=160), nullable=False), + sa.Column("request_hash", sa.String(length=64), nullable=False), + sa.Column("preview_hash", sa.String(length=64), nullable=False), + sa.Column("preflight_hash", sa.String(length=64), nullable=False), + sa.Column("trace_id", sa.String(length=128), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint( + "attempt_no > 0", + name="ck_privileged_identity_execution_attempts_number", + ), + sa.ForeignKeyConstraint( + ["operation_id"], + ["privileged_identity_operations.operation_id"], + name="fk_privileged_identity_execution_attempts_operation", + ondelete="NO ACTION", + ), + sa.PrimaryKeyConstraint("attempt_id"), + sa.UniqueConstraint( + "operation_id", + "attempt_no", + name="ux_privileged_identity_execution_attempts_operation_number", + ), + sa.UniqueConstraint( + "idempotency_key", + name="ux_privileged_identity_execution_attempts_idempotency_key", + ), + ) + op.create_index( + "ix_privileged_identity_execution_attempts_operation_created", + _ATTEMPT_TABLE, + ["operation_id", "created_at"], + ) + op.create_index( + "ix_privileged_identity_execution_attempts_trace", + _ATTEMPT_TABLE, + ["trace_id"], + ) + op.create_table( + _EVENT_TABLE, + sa.Column("event_id", sa.String(length=64), nullable=False), + sa.Column("attempt_id", sa.String(length=64), nullable=False), + sa.Column("sequence_no", sa.Integer(), nullable=False), + sa.Column("event_type", sa.String(length=40), nullable=False), + sa.Column("status_before", sa.String(length=32), nullable=True), + sa.Column("status_after", sa.String(length=32), nullable=False), + sa.Column("error_code", sa.String(length=128), nullable=True), + sa.Column("trace_id", sa.String(length=128), nullable=True), + sa.Column("snapshot_before", sa.JSON(), nullable=False), + sa.Column("snapshot_after", sa.JSON(), nullable=False), + sa.Column("snapshot_before_hash", sa.String(length=64), nullable=False), + sa.Column("snapshot_after_hash", sa.String(length=64), nullable=False), + sa.Column("event_payload_hash", sa.String(length=64), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint( + "event_type in ('attempt_prepared', 'attempt_started', 'attempt_succeeded', " + "'attempt_failed', 'compensation_required', 'attempt_compensated', " + "'compensation_failed', 'attempt_aborted')", + name="ck_privileged_identity_execution_attempt_events_type", + ), + sa.CheckConstraint( + "status_after in ('prepared', 'running', 'succeeded', 'failed', " + "'compensation_required', 'compensated', 'compensation_failed', 'aborted')", + name="ck_privileged_identity_execution_attempt_events_status_after", + ), + sa.CheckConstraint( + "status_before is null or status_before in ('prepared', 'running', 'succeeded', " + "'failed', 'compensation_required', 'compensated', 'compensation_failed', 'aborted')", + name="ck_privileged_identity_execution_attempt_events_status_before", + ), + sa.CheckConstraint( + "sequence_no > 0", + name="ck_privileged_identity_execution_attempt_events_sequence", + ), + sa.ForeignKeyConstraint( + ["attempt_id"], + [f"{_ATTEMPT_TABLE}.attempt_id"], + name="fk_privileged_identity_execution_attempt_events_attempt", + ondelete="NO ACTION", + ), + sa.PrimaryKeyConstraint("event_id"), + sa.UniqueConstraint( + "attempt_id", + "sequence_no", + name="ux_priv_id_exec_attempt_events_attempt_sequence", + ), + sa.UniqueConstraint( + "attempt_id", + "event_payload_hash", + name="ux_priv_id_exec_attempt_events_attempt_payload", + ), + ) + op.create_index( + "ix_privileged_identity_execution_attempt_events_attempt_created", + _EVENT_TABLE, + ["attempt_id", "created_at"], + ) + op.create_index( + "ix_privileged_identity_execution_attempt_events_trace", + _EVENT_TABLE, + ["trace_id"], + ) + _create_immutability_guards() + + +def downgrade() -> None: + _drop_immutability_guards() + op.drop_index( + "ix_privileged_identity_execution_attempt_events_trace", + table_name=_EVENT_TABLE, + ) + op.drop_index( + "ix_privileged_identity_execution_attempt_events_attempt_created", + table_name=_EVENT_TABLE, + ) + op.drop_table(_EVENT_TABLE) + op.drop_index( + "ix_privileged_identity_execution_attempts_trace", + table_name=_ATTEMPT_TABLE, + ) + op.drop_index( + "ix_privileged_identity_execution_attempts_operation_created", + table_name=_ATTEMPT_TABLE, + ) + op.drop_table(_ATTEMPT_TABLE) + + +def _create_immutability_guards() -> None: + bind = op.get_bind() + if bind.dialect.name == "sqlite": + for table_name in (_ATTEMPT_TABLE, _EVENT_TABLE): + for action in ("UPDATE", "DELETE"): + op.execute( + sa.text( + f""" + CREATE TRIGGER trg_{table_name}_{action.lower()}_blocked + BEFORE {action} ON {table_name} + BEGIN + SELECT RAISE(ABORT, 'privileged identity execution ledger is append-only'); + END + """ + ) + ) + elif bind.dialect.name == "postgresql": + op.execute( + sa.text( + """ + CREATE OR REPLACE FUNCTION privileged_identity_execution_ledger_immutable() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + RAISE EXCEPTION 'privileged identity execution ledger is append-only'; + END; + $$ + """ + ) + ) + for table_name in (_ATTEMPT_TABLE, _EVENT_TABLE): + op.execute( + sa.text( + f""" + CREATE TRIGGER trg_{table_name}_immutable + BEFORE UPDATE OR DELETE ON {table_name} + FOR EACH ROW EXECUTE FUNCTION privileged_identity_execution_ledger_immutable() + """ + ) + ) + + +def _drop_immutability_guards() -> None: + bind = op.get_bind() + if bind.dialect.name == "sqlite": + for table_name in (_ATTEMPT_TABLE, _EVENT_TABLE): + for action in ("update", "delete"): + op.execute( + sa.text(f"DROP TRIGGER IF EXISTS trg_{table_name}_{action}_blocked") + ) + elif bind.dialect.name == "postgresql": + for table_name in (_ATTEMPT_TABLE, _EVENT_TABLE): + op.execute( + sa.text( + f"DROP TRIGGER IF EXISTS trg_{table_name}_immutable ON {table_name}" + ) + ) + op.execute( + sa.text( + "DROP FUNCTION IF EXISTS privileged_identity_execution_ledger_immutable()" + ) + ) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/anti_abuse.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/anti_abuse.py index bb7939bf..4a977ee7 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/anti_abuse.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/anti_abuse.py @@ -621,6 +621,17 @@ def contract_endpoint_risk_policies(api_prefix: str) -> tuple[EndpointRiskPolicy "records", True, ), + EndpointRiskPolicy( + "identity.privileged_approval_sla.read", + "GET", + f"{p}/identity/privileged-approval-sla", + "S3", + 20, + 100, + 40, + "records", + True, + ), EndpointRiskPolicy( "identity.privileged_outbox.read", "GET", diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/identity.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/identity.py index ab697eb3..012f90df 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/identity.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/identity.py @@ -62,7 +62,13 @@ from yuqei_contract_api.identity import ( ) from yuqei_contract_api.persistence.models import DEFAULT_TENANT_ID from yuqei_contract_api.privileged_identity import ( + PrivilegedIdentityApprovalSlaOverview, + PrivilegedIdentityApprovalSlaQueuePage, + PrivilegedIdentityApprovalSlaQueueScope, + PrivilegedIdentityApprovalSlaStatus, PrivilegedIdentityDeliveryStatusPage, + PrivilegedIdentityExecutionAttemptPage, + PrivilegedIdentityExecutionPreflight, PrivilegedIdentityOperationApprovalRequest, PrivilegedIdentityOperationError, PrivilegedIdentityOperationEventPage, @@ -289,6 +295,98 @@ def register_identity_routes( except PrivilegedIdentityOperationError as exc: raise _privileged_operation_http_error(exc) from exc + @app.get( + f"{settings.api_prefix}/identity/privileged-operations/{{operation_id}}/execution-preflight", + response_model=PrivilegedIdentityExecutionPreflight, + tags=["identity"], + ) + def get_privileged_identity_operation_execution_preflight( + operation_id: str, + ) -> PrivilegedIdentityExecutionPreflight: + _require_privileged_platform_admin(settings, identity_repository) + privileged_repository = _privileged_operation_repository(identity_repository) + try: + return privileged_repository.get_execution_preflight( + operation_id, + actor_tenant_id=_current_tenant_id(), + actor_user_id=_current_user_id(), + ) + except PrivilegedIdentityOperationError as exc: + raise _privileged_operation_http_error(exc) from exc + + @app.get( + f"{settings.api_prefix}/identity/privileged-operations/{{operation_id}}/execution-attempts", + response_model=PrivilegedIdentityExecutionAttemptPage, + tags=["identity"], + ) + def list_privileged_identity_execution_attempts( + operation_id: str, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=100), + ) -> PrivilegedIdentityExecutionAttemptPage: + _require_privileged_platform_admin(settings, identity_repository) + privileged_repository = _privileged_operation_repository(identity_repository) + try: + return privileged_repository.list_execution_attempts( + operation_id, + actor_tenant_id=_current_tenant_id(), + actor_user_id=_current_user_id(), + page=page, + page_size=page_size, + ) + except PrivilegedIdentityOperationError as exc: + raise _privileged_operation_http_error(exc) from exc + + @app.get( + f"{settings.api_prefix}/identity/privileged-approval-sla/overview", + response_model=PrivilegedIdentityApprovalSlaOverview, + tags=["identity"], + ) + def get_privileged_identity_approval_sla_overview( + due_soon_seconds: int = Query(default=3_600, ge=60, le=86_400), + window_days: int = Query(default=7, ge=1, le=31), + ) -> PrivilegedIdentityApprovalSlaOverview: + _require_privileged_platform_admin(settings, identity_repository) + privileged_repository = _privileged_operation_repository(identity_repository) + try: + return privileged_repository.get_approval_sla_overview( + actor_tenant_id=_current_tenant_id(), + actor_user_id=_current_user_id(), + due_soon_seconds=due_soon_seconds, + window_days=window_days, + ) + except PrivilegedIdentityOperationError as exc: + raise _privileged_operation_http_error(exc) from exc + + @app.get( + f"{settings.api_prefix}/identity/privileged-approval-sla/queue", + response_model=PrivilegedIdentityApprovalSlaQueuePage, + tags=["identity"], + ) + def list_privileged_identity_approval_sla_queue( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + due_soon_seconds: int = Query(default=3_600, ge=60, le=86_400), + window_days: int = Query(default=7, ge=1, le=31), + sla_status: PrivilegedIdentityApprovalSlaStatus | None = None, + scope: PrivilegedIdentityApprovalSlaQueueScope = "window", + ) -> PrivilegedIdentityApprovalSlaQueuePage: + _require_privileged_platform_admin(settings, identity_repository) + privileged_repository = _privileged_operation_repository(identity_repository) + try: + return privileged_repository.list_approval_sla_queue( + actor_tenant_id=_current_tenant_id(), + actor_user_id=_current_user_id(), + due_soon_seconds=due_soon_seconds, + window_days=window_days, + page=page, + page_size=page_size, + sla_status=sla_status, + scope=scope, + ) + except PrivilegedIdentityOperationError as exc: + raise _privileged_operation_http_error(exc) from exc + @app.get( f"{settings.api_prefix}/identity/privileged-operations/{{operation_id}}/delivery-status", response_model=PrivilegedIdentityDeliveryStatusPage, diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/event_guards.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/event_guards.py index 5e0f5ab4..a96c60c3 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/event_guards.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/event_guards.py @@ -11,6 +11,11 @@ _POSTGRES_FUNCTION = "privileged_identity_operation_events_immutable" _OUTBOX_TABLE = "privileged_identity_outbox_messages" _SQLITE_OUTBOX_INSERT_TRIGGER = "trg_privileged_identity_outbox_event_operation_insert" _SQLITE_OUTBOX_UPDATE_TRIGGER = "trg_privileged_identity_outbox_event_operation_update" +_EXECUTION_LEDGER_TABLES = ( + "privileged_identity_execution_attempts", + "privileged_identity_execution_attempt_events", +) +_POSTGRES_EXECUTION_LEDGER_FUNCTION = "privileged_identity_execution_ledger_immutable" def install_privileged_identity_operation_event_guards(engine: Engine) -> None: @@ -80,6 +85,23 @@ def install_privileged_identity_operation_event_guards(engine: Engine) -> None: """ ) ) + for table_name in _EXECUTION_LEDGER_TABLES: + for action in ("UPDATE", "DELETE"): + connection.execute( + text( + f""" + CREATE TRIGGER IF NOT EXISTS + trg_{table_name}_{action.lower()}_blocked + BEFORE {action} ON {table_name} + BEGIN + SELECT RAISE( + ABORT, + 'privileged identity execution ledger is append-only' + ); + END + """ + ) + ) return if dialect == "postgresql": @@ -111,3 +133,30 @@ def install_privileged_identity_operation_event_guards(engine: Engine) -> None: f"FOR EACH ROW EXECUTE FUNCTION {_POSTGRES_FUNCTION}()" ) ) + connection.execute( + text( + f""" + CREATE OR REPLACE FUNCTION {_POSTGRES_EXECUTION_LEDGER_FUNCTION}() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + RAISE EXCEPTION 'privileged identity execution ledger is append-only'; + END; + $$ + """ + ) + ) + for table_name in _EXECUTION_LEDGER_TABLES: + trigger_name = f"trg_{table_name}_immutable" + connection.execute( + text(f"DROP TRIGGER IF EXISTS {trigger_name} ON {table_name}") + ) + connection.execute( + text( + f"CREATE TRIGGER {trigger_name} " + f"BEFORE UPDATE OR DELETE ON {table_name} " + f"FOR EACH ROW EXECUTE FUNCTION " + f"{_POSTGRES_EXECUTION_LEDGER_FUNCTION}()" + ) + ) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/models.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/models.py index e7a397e4..cccad33d 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/models.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/models.py @@ -237,6 +237,7 @@ class PrivilegedIdentityOperationModel(Base): expected_revisions: Mapped[list] = mapped_column(JSON, nullable=False, default=list) impact_summary: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) warnings: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + preview_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) @@ -324,6 +325,112 @@ class PrivilegedIdentityOperationEventModel(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) +class PrivilegedIdentityExecutionAttemptModel(Base): + __tablename__ = "privileged_identity_execution_attempts" + __table_args__ = ( + CheckConstraint( + "attempt_no > 0", + name="ck_privileged_identity_execution_attempts_number", + ), + UniqueConstraint( + "operation_id", + "attempt_no", + name="ux_privileged_identity_execution_attempts_operation_number", + ), + UniqueConstraint( + "idempotency_key", + name="ux_privileged_identity_execution_attempts_idempotency_key", + ), + Index( + "ix_privileged_identity_execution_attempts_operation_created", + "operation_id", + "created_at", + ), + Index( + "ix_privileged_identity_execution_attempts_trace", + "trace_id", + ), + ) + + attempt_id: Mapped[str] = mapped_column(String(64), primary_key=True) + operation_id: Mapped[str] = mapped_column( + String(64), + ForeignKey("privileged_identity_operations.operation_id", ondelete="NO ACTION"), + nullable=False, + ) + attempt_no: Mapped[int] = mapped_column(Integer, nullable=False) + idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False) + request_hash: Mapped[str] = mapped_column(String(64), nullable=False) + preview_hash: Mapped[str] = mapped_column(String(64), nullable=False) + preflight_hash: Mapped[str] = mapped_column(String(64), nullable=False) + trace_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class PrivilegedIdentityExecutionAttemptEventModel(Base): + __tablename__ = "privileged_identity_execution_attempt_events" + __table_args__ = ( + CheckConstraint( + "event_type in ('attempt_prepared', 'attempt_started', 'attempt_succeeded', " + "'attempt_failed', 'compensation_required', 'attempt_compensated', " + "'compensation_failed', 'attempt_aborted')", + name="ck_privileged_identity_execution_attempt_events_type", + ), + CheckConstraint( + "status_after in ('prepared', 'running', 'succeeded', 'failed', " + "'compensation_required', 'compensated', 'compensation_failed', 'aborted')", + name="ck_privileged_identity_execution_attempt_events_status_after", + ), + CheckConstraint( + "status_before is null or status_before in ('prepared', 'running', 'succeeded', " + "'failed', 'compensation_required', 'compensated', 'compensation_failed', 'aborted')", + name="ck_privileged_identity_execution_attempt_events_status_before", + ), + CheckConstraint( + "sequence_no > 0", + name="ck_privileged_identity_execution_attempt_events_sequence", + ), + UniqueConstraint( + "attempt_id", + "sequence_no", + name="ux_priv_id_exec_attempt_events_attempt_sequence", + ), + UniqueConstraint( + "attempt_id", + "event_payload_hash", + name="ux_priv_id_exec_attempt_events_attempt_payload", + ), + Index( + "ix_privileged_identity_execution_attempt_events_attempt_created", + "attempt_id", + "created_at", + ), + Index( + "ix_privileged_identity_execution_attempt_events_trace", + "trace_id", + ), + ) + + event_id: Mapped[str] = mapped_column(String(64), primary_key=True) + attempt_id: Mapped[str] = mapped_column( + String(64), + ForeignKey("privileged_identity_execution_attempts.attempt_id", ondelete="NO ACTION"), + nullable=False, + ) + sequence_no: Mapped[int] = mapped_column(Integer, nullable=False) + event_type: Mapped[str] = mapped_column(String(40), nullable=False) + status_before: Mapped[str | None] = mapped_column(String(32), nullable=True) + status_after: Mapped[str] = mapped_column(String(32), nullable=False) + error_code: Mapped[str | None] = mapped_column(String(128), nullable=True) + trace_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + snapshot_before: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + snapshot_after: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + snapshot_before_hash: Mapped[str] = mapped_column(String(64), nullable=False) + snapshot_after_hash: Mapped[str] = mapped_column(String(64), nullable=False) + event_payload_hash: Mapped[str] = mapped_column(String(64), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + class PrivilegedIdentityOutboxMessageModel(Base): __tablename__ = "privileged_identity_outbox_messages" __table_args__ = ( diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/privileged_identity.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/privileged_identity.py index ed6270e8..58ccb198 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/privileged_identity.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/privileged_identity.py @@ -1,5 +1,6 @@ from __future__ import annotations +from contextlib import nullcontext from dataclasses import dataclass from datetime import UTC, datetime, timedelta from hmac import compare_digest @@ -7,8 +8,8 @@ import re from typing import cast from uuid import uuid4 -from sqlalchemy import func, select -from sqlalchemy.exc import IntegrityError +from sqlalchemy import case, func, select, tuple_ +from sqlalchemy.exc import DBAPIError, IntegrityError from sqlalchemy.orm import Session, sessionmaker from yuqei_contract_api.logging import get_trace_id @@ -19,12 +20,20 @@ from yuqei_contract_api.persistence.models import ( PrivilegedIdentityOperationEventModel, PrivilegedIdentityOperationModel, PrivilegedIdentityOutboxMessageModel, + PrivilegedIdentityExecutionAttemptEventModel, + PrivilegedIdentityExecutionAttemptModel, TenantModel, TenantUserCredentialModel, TenantUserModel, WorkspaceMembershipModel, ) from yuqei_contract_api.privileged_identity import ( + PrivilegedIdentityApprovalSlaCounts, + PrivilegedIdentityApprovalSlaOverview, + PrivilegedIdentityApprovalSlaQueueItem, + PrivilegedIdentityApprovalSlaQueuePage, + PrivilegedIdentityApprovalSlaQueueScope, + PrivilegedIdentityApprovalSlaStatus, PrivilegedIdentityDeliveryStatusItem, PrivilegedIdentityDeliveryStatusPage, PrivilegedIdentityEvidenceRef, @@ -42,6 +51,14 @@ from yuqei_contract_api.privileged_identity import ( PrivilegedIdentityOperationEventType, PrivilegedIdentityOperationItem, PrivilegedIdentityOperationPage, + PrivilegedIdentityExecutionPreflight, + PrivilegedIdentityExecutionAttemptEventType, + PrivilegedIdentityExecutionAttemptItem, + PrivilegedIdentityExecutionAttemptPage, + PrivilegedIdentityExecutionAttemptStatus, + PrivilegedIdentityPreflightBlocker, + PrivilegedIdentityPreflightLock, + PrivilegedIdentityPreflightRevision, PrivilegedIdentityOperationPreview, PrivilegedIdentityOperationPreviewRequest, PrivilegedIdentityOperationStatus, @@ -50,6 +67,10 @@ from yuqei_contract_api.privileged_identity import ( normalize_privileged_json, privileged_payload_hash, ) +from yuqei_contract_api.privileged_identity_plans import ( + PrivilegedIdentityChangePlan, + build_privileged_identity_change_plan, +) _PREVIEW_TTL = timedelta(minutes=15) @@ -65,6 +86,88 @@ _SUPPORT_ACCESS_SCOPES = { _OUTBOX_EFFECT_TYPE = "privileged_identity.operation_event" _OUTBOX_SCHEMA_VERSION = 1 _OUTBOX_MAX_ATTEMPTS = 5 +_PREFLIGHT_REQUIRED_APPROVALS = 2 +_PREFLIGHT_LOCK_TIMEOUT_MIN_MS = 50 +_PREFLIGHT_LOCK_TIMEOUT_MAX_MS = 10_000 +_PREFLIGHT_LOCK_RANKS = { + "privileged_operation": 10, + "privileged_approval_set": 20, + "identity_account": 100, + "workspace": 200, + "tenant_user": 300, + "workspace_membership": 400, + "identity_account_credential": 500, + "tenant_user_credential": 600, +} +_PREFLIGHT_LOCK_MODEL_COLUMNS = { + "identity_account": (GlobalAccountModel, GlobalAccountModel.id), + "workspace": (TenantModel, TenantModel.id), + "tenant_user": (TenantUserModel, TenantUserModel.id), + "workspace_membership": (WorkspaceMembershipModel, WorkspaceMembershipModel.id), + "identity_account_credential": ( + GlobalAccountCredentialModel, + GlobalAccountCredentialModel.id, + ), + "tenant_user_credential": ( + TenantUserCredentialModel, + TenantUserCredentialModel.id, + ), +} +_EXECUTION_ATTEMPT_EVENT_STATUS: dict[ + PrivilegedIdentityExecutionAttemptEventType, + PrivilegedIdentityExecutionAttemptStatus, +] = { + "attempt_prepared": "prepared", + "attempt_started": "running", + "attempt_succeeded": "succeeded", + "attempt_failed": "failed", + "compensation_required": "compensation_required", + "attempt_compensated": "compensated", + "compensation_failed": "compensation_failed", + "attempt_aborted": "aborted", +} +_EXECUTION_ATTEMPT_TRANSITIONS: dict[ + PrivilegedIdentityExecutionAttemptStatus | None, + frozenset[PrivilegedIdentityExecutionAttemptStatus], +] = { + None: frozenset({"prepared"}), + "prepared": frozenset({"running", "aborted"}), + "running": frozenset({"succeeded", "failed", "compensation_required"}), + "succeeded": frozenset(), + "failed": frozenset(), + "compensation_required": frozenset({"compensated", "compensation_failed"}), + "compensated": frozenset(), + "compensation_failed": frozenset(), + "aborted": frozenset(), +} +_EXECUTION_ATTEMPT_ERROR_CODES = frozenset( + { + "preflight_failed", + "lock_conflict", + "revision_mismatch", + "dependency_unavailable", + "provider_timeout", + "provider_rejected", + "compensation_failed", + "unknown", + } +) +_EXECUTION_ATTEMPT_SNAPSHOT_KEYS = frozenset( + { + "status", + "operation_type", + "attempt_no", + "resource_count", + "step_count", + "lock_count", + "lock_plan_hash", + "preflight_hash", + "preview_hash", + "result_code", + "compensation_required", + "retryable", + } +) _TARGET_REF_KEYS: dict[PrivilegedIdentityOperationType, set[str]] = { "account_merge": {"source_account_id", "target_account_id"}, "account_split": {"source_account_id", "destination_account_id", "membership_partition"}, @@ -180,6 +283,7 @@ class PrivilegedIdentityOperationRepository: expected_revisions=[item.model_dump(mode="json") for item in preview.expected_revisions], impact_summary=preview.impact_summary, warnings=preview.warnings, + preview_expires_at=normalized_preview_expiry, expires_at=operation_expiry, created_at=now, updated_at=now, @@ -290,6 +394,913 @@ class PrivilegedIdentityOperationRepository: return None return self._operation_from_model(session, model, now=now) + def get_execution_preflight( + self, + operation_id: str, + *, + actor_tenant_id: str, + actor_user_id: str, + _session: Session | None = None, + _locks_acquired: bool = False, + ) -> PrivilegedIdentityExecutionPreflight: + now = datetime.now(UTC) + session_context = ( + nullcontext(_session) + if _session is not None + else self._session_factory() + ) + with session_context as session: + self._require_current_platform_admin( + session, + tenant_id=actor_tenant_id, + user_id=actor_user_id, + ) + operation = session.get(PrivilegedIdentityOperationModel, operation_id) + if operation is None: + raise _error(404, "operation_not_found", "Privileged identity operation not found.") + + blockers: list[PrivilegedIdentityPreflightBlocker] = [] + effective_status = cast(PrivilegedIdentityOperationStatus, operation.status) + if ( + effective_status in {"pending_approval", "approved"} + and _as_utc(operation.expires_at) <= now + ): + effective_status = "expired" + if effective_status == "expired": + _append_preflight_blocker( + blockers, + "operation_expired", + "The operation approval lifetime has expired.", + ) + elif effective_status != "approved": + _append_preflight_blocker( + blockers, + "operation_not_approved", + "The operation has not completed two-person approval.", + ) + + valid_approval_accounts = self._preflight_valid_approval_accounts( + session, + operation, + now=now, + blockers=blockers, + ) + if len(valid_approval_accounts) < _PREFLIGHT_REQUIRED_APPROVALS: + _append_preflight_blocker( + blockers, + "approval_quorum_missing", + "Two distinct current platform administrators are required.", + ) + + expected_revisions: list[PrivilegedIdentityExpectedRevision] = [] + raw_expected_revisions = operation.expected_revisions + if not isinstance(raw_expected_revisions, list) or not raw_expected_revisions: + _append_preflight_blocker( + blockers, + "expected_revisions_missing", + "The operation does not contain revision verification material.", + ) + else: + try: + expected_revisions = [ + PrivilegedIdentityExpectedRevision.model_validate(item) + for item in raw_expected_revisions + ] + except (TypeError, ValueError): + _append_preflight_blocker( + blockers, + "expected_revisions_invalid", + "The stored revision verification material is invalid.", + ) + else: + revision_keys = { + (item.entity_type, item.entity_id) + for item in expected_revisions + } + if len(revision_keys) != len(expected_revisions): + _append_preflight_blocker( + blockers, + "expected_revisions_invalid", + "The stored revision verification material contains duplicate resources.", + ) + revision_checks = [ + _preflight_revision(session, item) + for item in sorted( + expected_revisions, + key=lambda item: ( + _PREFLIGHT_LOCK_RANKS.get(item.entity_type, 10_000), + item.entity_type, + item.entity_id, + ), + ) + ] + if any(not item.matches for item in revision_checks): + _append_preflight_blocker( + blockers, + "revision_mismatch", + "One or more identity entities no longer match the approved revision.", + ) + if any(item.entity_type not in _PREFLIGHT_LOCK_RANKS for item in revision_checks): + _append_preflight_blocker( + blockers, + "unsupported_lock_resource", + "The operation contains an unsupported lock resource.", + ) + + preview_hash_verified, rebuilt_revisions = self._verify_preflight_preview( + session, + operation, + now=now, + blockers=blockers, + ) + if ( + rebuilt_revisions is not None + and _preflight_revision_scope(expected_revisions) + != _preflight_revision_scope(rebuilt_revisions) + ): + _append_preflight_blocker( + blockers, + "expected_revisions_mismatch", + "The stored revision set does not match the current preview lock scope.", + ) + approval_revision = _approval_set_revision(session, operation.operation_id) + lock_rows = [ + ( + "privileged_operation", + operation.operation_id, + None, + _revision_value(operation.updated_at), + True, + ), + ( + "privileged_approval_set", + operation.operation_id, + None, + approval_revision, + True, + ), + *[ + ( + item.entity_type, + item.entity_id, + item.expected_revision, + item.current_revision, + item.matches, + ) + for item in revision_checks + ], + ] + lock_rows.sort( + key=lambda item: ( + _PREFLIGHT_LOCK_RANKS.get(item[0], 10_000), + item[0], + item[1], + ) + ) + lock_plan = [ + PrivilegedIdentityPreflightLock( + sequence=index, + resource_type=resource_type, + resource_id=resource_id, + expected_revision=expected_revision, + current_revision=current_revision, + matches=matches, + ) + for index, ( + resource_type, + resource_id, + expected_revision, + current_revision, + matches, + ) in enumerate(lock_rows, start=1) + ] + return PrivilegedIdentityExecutionPreflight( + operation_id=operation.operation_id, + operation_type=operation.operation_type, + stored_status=operation.status, + effective_status=effective_status, + generated_at=now, + expires_at=_as_utc(operation.expires_at), + preview_expires_at=( + _as_utc(operation.preview_expires_at) + if operation.preview_expires_at is not None + else None + ), + preview_hash_verified=preview_hash_verified, + valid_approval_count=len(valid_approval_accounts), + required_approval_count=_PREFLIGHT_REQUIRED_APPROVALS, + ready=not blockers, + blockers=blockers, + revisions=revision_checks, + lock_plan=lock_plan, + locks_acquired=_locks_acquired, + executor_enabled=False, + ) + + def recheck_execution_preflight_under_lock( + self, + operation_id: str, + *, + actor_tenant_id: str, + actor_user_id: str, + lock_timeout_ms: int = 2_000, + ) -> PrivilegedIdentityExecutionPreflight: + """Acquire the fixed plan and recheck preflight; all locks are released on return.""" + if not _PREFLIGHT_LOCK_TIMEOUT_MIN_MS <= lock_timeout_ms <= _PREFLIGHT_LOCK_TIMEOUT_MAX_MS: + raise _error( + 422, + "execution_lock_timeout_invalid", + "Execution lock timeout must be between 50 and 10000 milliseconds.", + ) + + with self._session_factory() as session: + dialect = session.get_bind().dialect.name + if dialect not in {"postgresql", "sqlite"}: + raise _error( + 409, + "execution_locking_not_supported", + "Execution preflight locking is only supported by PostgreSQL.", + ) + try: + with session.begin(): + actor_account_id, _ = self._require_current_platform_admin( + session, + tenant_id=actor_tenant_id, + user_id=actor_user_id, + ) + if dialect == "postgresql": + session.execute( + select( + func.set_config( + "lock_timeout", + f"{lock_timeout_ms}ms", + True, + ) + ) + ).scalar_one() + operation = session.execute( + select(PrivilegedIdentityOperationModel) + .where(PrivilegedIdentityOperationModel.operation_id == operation_id) + .with_for_update() + ).scalar_one_or_none() + if operation is None: + raise _error( + 404, + "operation_not_found", + "Privileged identity operation not found.", + ) + self._lock_execution_preflight_resources( + session, + operation, + actor_account_id=actor_account_id, + actor_tenant_id=actor_tenant_id, + actor_user_id=actor_user_id, + ) + # Re-read every principal and revision after lock waits; the initial + # authorization query may have populated older ORM identity-map state. + session.expire_all() + preflight = self.get_execution_preflight( + operation_id, + actor_tenant_id=actor_tenant_id, + actor_user_id=actor_user_id, + _session=session, + _locks_acquired=(dialect == "postgresql"), + ) + if session.new or session.dirty or session.deleted: + raise _error( + 500, + "execution_lock_recheck_not_read_only", + "Execution preflight lock recheck attempted to mutate persistence state.", + ) + return preflight + except DBAPIError as exc: + if dialect == "postgresql" and _is_postgres_lock_timeout(exc): + raise _error( + 423, + "execution_lock_timeout", + "Execution preflight locks could not be acquired within the configured timeout.", + ) from exc + raise + + def build_internal_change_plan( + self, + operation_id: str, + *, + actor_tenant_id: str, + actor_user_id: str, + ) -> PrivilegedIdentityChangePlan: + """Build a validated plan from a ready read-only preflight. + + This internal helper deliberately has no API binding and performs no + identity mutation. A caller must still obtain a fresh fixed-lock + preflight immediately before any future executor is introduced. + """ + + with self._session_factory() as session: + self._require_current_platform_admin( + session, + tenant_id=actor_tenant_id, + user_id=actor_user_id, + ) + operation = session.get(PrivilegedIdentityOperationModel, operation_id) + if operation is None: + raise _error(404, "operation_not_found", "Privileged identity operation not found.") + preflight = self.get_execution_preflight( + operation_id, + actor_tenant_id=actor_tenant_id, + actor_user_id=actor_user_id, + _session=session, + ) + if not preflight.ready: + raise _error( + 409, + "change_plan_preflight_not_ready", + "A deterministic identity change plan requires a ready preflight.", + ) + return build_privileged_identity_change_plan( + operation_id=operation.operation_id, + operation_type=operation.operation_type, + target_refs=dict(operation.target_refs or {}), + expected_revisions=list(operation.expected_revisions or []), + lock_plan=preflight.lock_plan, + ) + + @staticmethod + def _lock_execution_preflight_resources( + session: Session, + operation: PrivilegedIdentityOperationModel, + *, + actor_account_id: str, + actor_tenant_id: str, + actor_user_id: str, + ) -> None: + approvals = session.execute( + select(PrivilegedIdentityOperationApprovalModel) + .where( + PrivilegedIdentityOperationApprovalModel.operation_id + == operation.operation_id + ) + .order_by(PrivilegedIdentityOperationApprovalModel.id) + .with_for_update() + ).scalars().all() + + try: + expected_revisions = [ + PrivilegedIdentityExpectedRevision.model_validate(item) + for item in (operation.expected_revisions or []) + ] + except (TypeError, ValueError): + expected_revisions = [] + revision_ids: dict[str, set[str]] = {} + for revision in expected_revisions: + if revision.entity_type in _PREFLIGHT_LOCK_MODEL_COLUMNS: + revision_ids.setdefault(revision.entity_type, set()).add(revision.entity_id) + principal_refs = { + (actor_account_id, actor_tenant_id, actor_user_id), + *{ + ( + approval.approver_account_id, + approval.approver_tenant_id, + approval.approver_user_id, + ) + for approval in approvals + }, + } + revision_ids.setdefault("identity_account", set()).update( + account_id for account_id, _, _ in principal_refs + ) + revision_ids.setdefault("workspace", set()).update( + tenant_id for _, tenant_id, _ in principal_refs + ) + revision_ids.setdefault("tenant_user", set()).update( + user_id for _, _, user_id in principal_refs + ) + revision_ids.setdefault("workspace_membership", set()) + for resource_type in sorted( + revision_ids, + key=lambda item: (_PREFLIGHT_LOCK_RANKS[item], item), + ): + if resource_type == "workspace_membership" and principal_refs: + permission_membership_ids = session.execute( + select(WorkspaceMembershipModel.id).where( + tuple_( + WorkspaceMembershipModel.account_id, + WorkspaceMembershipModel.tenant_id, + WorkspaceMembershipModel.tenant_user_id, + ).in_(sorted(principal_refs)) + ) + ).scalars().all() + revision_ids[resource_type].update(permission_membership_ids) + model, id_column = _PREFLIGHT_LOCK_MODEL_COLUMNS[resource_type] + session.execute( + select(model) + .where(id_column.in_(sorted(revision_ids[resource_type]))) + .order_by(id_column) + .with_for_update() + ).scalars().all() + + def register_execution_attempt( + self, + operation_id: str, + *, + idempotency_key: str, + request_hash: str, + preview_hash: str, + preflight_hash: str, + trace_id: str | None = None, + ) -> PrivilegedIdentityExecutionAttemptItem: + """Register an attempt and its immutable prepared event for internal callers only.""" + _validate_execution_attempt_idempotency_key(idempotency_key) + _validate_execution_attempt_hash(request_hash, field="request_hash") + _validate_execution_attempt_hash(preview_hash, field="preview_hash") + _validate_execution_attempt_hash(preflight_hash, field="preflight_hash") + safe_trace_id = _safe_trace_id(trace_id or get_trace_id()) + now = datetime.now(UTC) + with self._session_factory.begin() as session: + existing = session.execute( + select(PrivilegedIdentityExecutionAttemptModel).where( + PrivilegedIdentityExecutionAttemptModel.idempotency_key + == idempotency_key + ) + ).scalar_one_or_none() + if existing is not None: + if not _execution_attempt_matches( + existing, + operation_id=operation_id, + request_hash=request_hash, + preview_hash=preview_hash, + preflight_hash=preflight_hash, + ): + raise _error( + 409, + "execution_attempt_idempotency_conflict", + "The execution attempt idempotency key is already bound to a different request.", + ) + return _execution_attempt_item_from_model(session, existing) + + operation = session.execute( + select(PrivilegedIdentityOperationModel) + .where(PrivilegedIdentityOperationModel.operation_id == operation_id) + .with_for_update() + ).scalar_one_or_none() + if operation is None: + raise _error(404, "operation_not_found", "Privileged identity operation not found.") + if operation.status != "approved": + raise _error( + 409, + "execution_attempt_operation_not_approved", + "Execution attempts require an approved privileged identity operation.", + ) + if _as_utc(operation.expires_at) <= now: + raise _error( + 409, + "execution_attempt_operation_expired", + "Execution attempts cannot be registered for an expired operation.", + ) + if not compare_digest(operation.preview_hash, preview_hash): + raise _error( + 409, + "execution_attempt_preview_mismatch", + "The execution attempt preview hash does not match the operation.", + ) + latest_attempt_no = session.execute( + select(func.max(PrivilegedIdentityExecutionAttemptModel.attempt_no)).where( + PrivilegedIdentityExecutionAttemptModel.operation_id == operation_id + ) + ).scalar_one() + attempt_no = int(latest_attempt_no or 0) + 1 + attempt_id = "privileged-attempt-" + privileged_payload_hash( + {"operation_id": operation_id, "idempotency_key": idempotency_key} + )[:24] + attempt = PrivilegedIdentityExecutionAttemptModel( + attempt_id=attempt_id, + operation_id=operation_id, + attempt_no=attempt_no, + idempotency_key=idempotency_key, + request_hash=request_hash, + preview_hash=preview_hash, + preflight_hash=preflight_hash, + trace_id=safe_trace_id, + created_at=now, + ) + try: + with session.begin_nested(): + session.add(attempt) + session.flush() + except IntegrityError as exc: + concurrent = session.execute( + select(PrivilegedIdentityExecutionAttemptModel).where( + PrivilegedIdentityExecutionAttemptModel.idempotency_key + == idempotency_key + ) + ).scalar_one_or_none() + if concurrent is not None and _execution_attempt_matches( + concurrent, + operation_id=operation_id, + request_hash=request_hash, + preview_hash=preview_hash, + preflight_hash=preflight_hash, + ): + return _execution_attempt_item_from_model(session, concurrent) + raise _error( + 409, + "execution_attempt_registration_conflict", + "A concurrent execution attempt registration must be retried.", + ) from exc + self._append_execution_attempt_event_in_session( + session, + attempt, + event_type="attempt_prepared", + status_before=None, + status_after="prepared", + error_code=None, + trace_id=safe_trace_id, + snapshot_before={}, + snapshot_after={ + "status": "prepared", + "operation_type": operation.operation_type, + "attempt_no": attempt_no, + }, + now=now, + ) + return _execution_attempt_item_from_model(session, attempt) + + def append_execution_attempt_event( + self, + attempt_id: str, + *, + event_type: PrivilegedIdentityExecutionAttemptEventType, + status_before: PrivilegedIdentityExecutionAttemptStatus | None, + status_after: PrivilegedIdentityExecutionAttemptStatus, + snapshot_before: dict[str, object], + snapshot_after: dict[str, object], + error_code: str | None = None, + trace_id: str | None = None, + ) -> PrivilegedIdentityExecutionAttemptItem: + """Append one immutable state transition for internal callers only.""" + now = datetime.now(UTC) + with self._session_factory.begin() as session: + attempt = session.execute( + select(PrivilegedIdentityExecutionAttemptModel) + .where(PrivilegedIdentityExecutionAttemptModel.attempt_id == attempt_id) + .with_for_update() + ).scalar_one_or_none() + if attempt is None: + raise _error(404, "execution_attempt_not_found", "Execution attempt not found.") + self._append_execution_attempt_event_in_session( + session, + attempt, + event_type=event_type, + status_before=status_before, + status_after=status_after, + error_code=error_code, + trace_id=_safe_trace_id(trace_id or get_trace_id() or attempt.trace_id), + snapshot_before=snapshot_before, + snapshot_after=snapshot_after, + now=now, + ) + return _execution_attempt_item_from_model(session, attempt) + + def _append_execution_attempt_event_in_session( + self, + session: Session, + attempt: PrivilegedIdentityExecutionAttemptModel, + *, + event_type: PrivilegedIdentityExecutionAttemptEventType, + status_before: PrivilegedIdentityExecutionAttemptStatus | None, + status_after: PrivilegedIdentityExecutionAttemptStatus, + error_code: str | None, + trace_id: str | None, + snapshot_before: dict[str, object], + snapshot_after: dict[str, object], + now: datetime, + ) -> None: + expected_status_after = _EXECUTION_ATTEMPT_EVENT_STATUS.get(event_type) + if expected_status_after is None or status_after != expected_status_after: + raise _error( + 422, + "execution_attempt_event_invalid", + "The execution attempt event type and status do not match.", + ) + _validate_execution_attempt_transition(status_before, status_after) + if event_type in {"attempt_failed", "compensation_failed"}: + _validate_execution_attempt_error_code(error_code) + elif error_code is not None: + raise _error( + 422, + "execution_attempt_error_code_invalid", + "An error code is only valid for a failed execution attempt event.", + ) + _validate_execution_attempt_snapshot(snapshot_before, expected_status=status_before) + _validate_execution_attempt_snapshot(snapshot_after, expected_status=status_after) + current_event = session.execute( + select(PrivilegedIdentityExecutionAttemptEventModel) + .where( + PrivilegedIdentityExecutionAttemptEventModel.attempt_id == attempt.attempt_id + ) + .order_by(PrivilegedIdentityExecutionAttemptEventModel.sequence_no.desc()) + .limit(1) + ).scalar_one_or_none() + current_status = ( + cast(PrivilegedIdentityExecutionAttemptStatus, current_event.status_after) + if current_event is not None + else None + ) + payload = { + "attempt_id": attempt.attempt_id, + "event_type": event_type, + "status_before": status_before, + "status_after": status_after, + "error_code": error_code, + "snapshot_before_hash": privileged_payload_hash(snapshot_before), + "snapshot_after_hash": privileged_payload_hash(snapshot_after), + } + event_payload_hash = privileged_payload_hash(payload) + event_id = "privileged-attempt-event-" + event_payload_hash[:24] + replay = session.get(PrivilegedIdentityExecutionAttemptEventModel, event_id) + if replay is not None: + if replay.event_payload_hash != event_payload_hash: + raise _error( + 409, + "execution_attempt_event_conflict", + "The execution attempt event identifier is already bound to another event.", + ) + return + if current_status != status_before: + raise _error( + 409, + "execution_attempt_transition_conflict", + "The execution attempt state has changed and must be re-read before appending.", + ) + sequence_no = int( + session.execute( + select(func.max(PrivilegedIdentityExecutionAttemptEventModel.sequence_no)).where( + PrivilegedIdentityExecutionAttemptEventModel.attempt_id == attempt.attempt_id + ) + ).scalar_one() + or 0 + ) + 1 + event = PrivilegedIdentityExecutionAttemptEventModel( + event_id=event_id, + attempt_id=attempt.attempt_id, + sequence_no=sequence_no, + event_type=event_type, + status_before=status_before, + status_after=status_after, + error_code=error_code, + trace_id=trace_id, + snapshot_before=normalize_privileged_json(snapshot_before), + snapshot_after=normalize_privileged_json(snapshot_after), + snapshot_before_hash=privileged_payload_hash(snapshot_before), + snapshot_after_hash=privileged_payload_hash(snapshot_after), + event_payload_hash=event_payload_hash, + created_at=now, + ) + try: + with session.begin_nested(): + session.add(event) + session.flush() + except IntegrityError as exc: + concurrent = session.get(PrivilegedIdentityExecutionAttemptEventModel, event_id) + if concurrent is not None and concurrent.event_payload_hash == event_payload_hash: + return + raise _error( + 409, + "execution_attempt_append_conflict", + "A concurrent execution attempt event append must be retried.", + ) from exc + + def list_execution_attempts( + self, + operation_id: str, + *, + actor_tenant_id: str, + actor_user_id: str, + page: int = 1, + page_size: int = 50, + ) -> PrivilegedIdentityExecutionAttemptPage: + with self._session_factory() as session: + self._require_current_platform_admin( + session, + tenant_id=actor_tenant_id, + user_id=actor_user_id, + ) + operation = session.get(PrivilegedIdentityOperationModel, operation_id) + if operation is None: + raise _error(404, "operation_not_found", "Privileged identity operation not found.") + total = int( + session.execute( + select(func.count(PrivilegedIdentityExecutionAttemptModel.attempt_id)).where( + PrivilegedIdentityExecutionAttemptModel.operation_id == operation_id + ) + ).scalar_one() + ) + attempts = session.execute( + select(PrivilegedIdentityExecutionAttemptModel) + .where(PrivilegedIdentityExecutionAttemptModel.operation_id == operation_id) + .order_by(PrivilegedIdentityExecutionAttemptModel.attempt_no) + .offset((page - 1) * page_size) + .limit(page_size) + ).scalars().all() + return PrivilegedIdentityExecutionAttemptPage( + items=[_execution_attempt_item_from_model(session, attempt) for attempt in attempts], + total=total, + page=page, + page_size=page_size, + executor_enabled=False, + ) + + def get_approval_sla_overview( + self, + *, + actor_tenant_id: str, + actor_user_id: str, + due_soon_seconds: int, + window_days: int, + ) -> PrivilegedIdentityApprovalSlaOverview: + now = datetime.now(UTC) + with self._session_factory() as session: + self._require_current_platform_admin( + session, + tenant_id=actor_tenant_id, + user_id=actor_user_id, + ) + window_start = now - timedelta(days=window_days) + operations = session.execute( + select(PrivilegedIdentityOperationModel).where( + PrivilegedIdentityOperationModel.status == "pending_approval" + ) + ).scalars().all() + counts = PrivilegedIdentityApprovalSlaCounts() + window_operations = [ + operation + for operation in operations + if _as_utc(operation.created_at) >= window_start + ] + outside_window_operations = [ + operation + for operation in operations + if _as_utc(operation.created_at) < window_start + ] + for operation in window_operations: + sla_status = self._approval_sla_status( + operation, + now=now, + due_soon_seconds=due_soon_seconds, + ) + setattr(counts, sla_status, getattr(counts, sla_status) + 1) + oldest_created_at = min( + (_as_utc(operation.created_at) for operation in operations), + default=None, + ) + return PrivilegedIdentityApprovalSlaOverview( + generated_at=now, + due_soon_seconds=due_soon_seconds, + window_days=window_days, + total=len(window_operations), + counts=counts, + all_pending_total=len(operations), + outside_window_pending_count=len(outside_window_operations), + outside_window_breached_count=sum( + _as_utc(operation.expires_at) <= now + for operation in outside_window_operations + ), + oldest_pending_age_seconds=( + max(0, int((now - oldest_created_at).total_seconds())) + if oldest_created_at is not None + else None + ), + ) + + def list_approval_sla_queue( + self, + *, + actor_tenant_id: str, + actor_user_id: str, + due_soon_seconds: int, + window_days: int, + page: int, + page_size: int, + sla_status: PrivilegedIdentityApprovalSlaStatus | None = None, + scope: PrivilegedIdentityApprovalSlaQueueScope = "window", + ) -> PrivilegedIdentityApprovalSlaQueuePage: + now = datetime.now(UTC) + with self._session_factory() as session: + self._require_current_platform_admin( + session, + tenant_id=actor_tenant_id, + user_id=actor_user_id, + ) + window_start = now - timedelta(days=window_days) + due_soon_at = now + timedelta(seconds=due_soon_seconds) + filters = [PrivilegedIdentityOperationModel.status == "pending_approval"] + if scope == "window": + filters.append(PrivilegedIdentityOperationModel.created_at >= window_start) + if sla_status == "breached": + filters.append(PrivilegedIdentityOperationModel.expires_at <= now) + elif sla_status == "due_soon": + filters.extend( + ( + PrivilegedIdentityOperationModel.expires_at > now, + PrivilegedIdentityOperationModel.expires_at <= due_soon_at, + ) + ) + elif sla_status == "on_track": + filters.append(PrivilegedIdentityOperationModel.expires_at > due_soon_at) + + sla_rank = case( + (PrivilegedIdentityOperationModel.expires_at <= now, 0), + (PrivilegedIdentityOperationModel.expires_at <= due_soon_at, 1), + else_=2, + ) + total = session.scalar( + select(func.count()) + .select_from(PrivilegedIdentityOperationModel) + .where(*filters) + ) or 0 + offset = (page - 1) * page_size + operations = session.execute( + select(PrivilegedIdentityOperationModel) + .where(*filters) + .order_by( + sla_rank, + PrivilegedIdentityOperationModel.expires_at, + PrivilegedIdentityOperationModel.operation_id, + ) + .offset(offset) + .limit(page_size) + ).scalars().all() + return PrivilegedIdentityApprovalSlaQueuePage( + items=[ + self._approval_sla_queue_item( + session, + operation, + now=now, + due_soon_seconds=due_soon_seconds, + ) + for operation in operations + ], + total=total, + page=page, + page_size=page_size, + total_pages=(total + page_size - 1) // page_size, + generated_at=now, + due_soon_seconds=due_soon_seconds, + window_days=window_days, + scope=scope, + ) + + def _approval_sla_queue_item( + self, + session: Session, + operation: PrivilegedIdentityOperationModel, + *, + now: datetime, + due_soon_seconds: int, + ) -> PrivilegedIdentityApprovalSlaQueueItem: + expires_at = _as_utc(operation.expires_at) + sla_status = self._approval_sla_status( + operation, + now=now, + due_soon_seconds=due_soon_seconds, + ) + valid_approval_count = len( + self._preflight_valid_approval_accounts( + session, + operation, + now=now, + blockers=[], + ) + ) + return PrivilegedIdentityApprovalSlaQueueItem( + operation_id=operation.operation_id, + operation_type=operation.operation_type, + sla_status=sla_status, + valid_approval_count=valid_approval_count, + required_approval_count=_PREFLIGHT_REQUIRED_APPROVALS, + remaining_approval_count=max( + 0, + _PREFLIGHT_REQUIRED_APPROVALS - valid_approval_count, + ), + created_at=_as_utc(operation.created_at), + expires_at=expires_at, + seconds_until_due=int((expires_at - now).total_seconds()), + ) + + @staticmethod + def _approval_sla_status( + operation: PrivilegedIdentityOperationModel, + *, + now: datetime, + due_soon_seconds: int, + ) -> PrivilegedIdentityApprovalSlaStatus: + expires_at = _as_utc(operation.expires_at) + if expires_at <= now: + return "breached" + if expires_at <= now + timedelta(seconds=due_soon_seconds): + return "due_soon" + return "on_track" + def list_events( self, operation_id: str, @@ -1007,6 +2018,143 @@ class PrivilegedIdentityOperationRepository: ).scalar_one() ) + def _preflight_valid_approval_accounts( + self, + session: Session, + operation: PrivilegedIdentityOperationModel, + *, + now: datetime, + blockers: list[PrivilegedIdentityPreflightBlocker], + ) -> set[str]: + approvals = session.execute( + select(PrivilegedIdentityOperationApprovalModel).where( + PrivilegedIdentityOperationApprovalModel.operation_id + == operation.operation_id, + PrivilegedIdentityOperationApprovalModel.decision == "approve", + ) + ).scalars().all() + valid_accounts: set[str] = set() + for approval in approvals: + if _as_utc(approval.expires_at) <= now: + _append_preflight_blocker( + blockers, + "approval_expired", + "An approval has expired.", + ) + continue + if approval.preview_hash != operation.preview_hash: + _append_preflight_blocker( + blockers, + "approval_preview_mismatch", + "An approval references a different preview hash.", + ) + continue + if approval.approver_account_id == operation.requested_by_account_id: + _append_preflight_blocker( + blockers, + "requester_approval_present", + "The requester cannot count toward the approval quorum.", + ) + continue + account = session.get(GlobalAccountModel, approval.approver_account_id) + tenant = session.get(TenantModel, approval.approver_tenant_id) + user = session.get(TenantUserModel, approval.approver_user_id) + membership = session.execute( + select(WorkspaceMembershipModel).where( + WorkspaceMembershipModel.account_id == approval.approver_account_id, + WorkspaceMembershipModel.tenant_id == approval.approver_tenant_id, + WorkspaceMembershipModel.tenant_user_id == approval.approver_user_id, + WorkspaceMembershipModel.status == "active", + ) + ).scalar_one_or_none() + if ( + account is None + or account.status != "active" + or tenant is None + or tenant.status != "active" + or tenant.deleted_at is not None + or user is None + or user.tenant_id != approval.approver_tenant_id + or user.status != "active" + or user.deleted_at is not None + or membership is None + or "platform_admin" not in (user.platform_permissions or []) + ): + _append_preflight_blocker( + blockers, + "approver_not_current", + "An approver is no longer an active platform administrator.", + ) + continue + if approval.permission_revision != _revision_value(user.updated_at): + _append_preflight_blocker( + blockers, + "approval_permission_revision_mismatch", + "An approver permission revision changed after approval.", + ) + continue + valid_accounts.add(approval.approver_account_id) + return valid_accounts + + def _verify_preflight_preview( + self, + session: Session, + operation: PrivilegedIdentityOperationModel, + *, + now: datetime, + blockers: list[PrivilegedIdentityPreflightBlocker], + ) -> tuple[bool, list[PrivilegedIdentityExpectedRevision] | None]: + if operation.preview_expires_at is None: + _append_preflight_blocker( + blockers, + "preview_verification_material_missing", + "The historical operation does not store its preview expiry.", + ) + return False, None + if not isinstance(operation.evidence_refs, list): + _append_preflight_blocker( + blockers, + "preview_verification_material_invalid", + "The stored preview verification material is invalid.", + ) + return False, None + try: + preview = self._build_preview( + session, + PrivilegedIdentityOperationPreviewRequest( + operation_type=operation.operation_type, + target_refs=dict(operation.target_refs or {}), + evidence_refs=[ + PrivilegedIdentityEvidenceRef.model_validate(item) + for item in operation.evidence_refs + ], + ), + now=now, + expires_at=_as_utc(operation.preview_expires_at), + ) + except PrivilegedIdentityOperationError as exc: + _append_preflight_blocker( + blockers, + "current_state_invalid", + f"Current identity state failed preview validation: {exc.code}.", + ) + return False, None + except (TypeError, ValueError): + _append_preflight_blocker( + blockers, + "preview_verification_material_invalid", + "The stored preview verification material is invalid.", + ) + return False, None + verified = compare_digest(preview.preview_hash, operation.preview_hash) + if not verified: + _append_preflight_blocker( + blockers, + "preview_hash_mismatch", + "The approved preview no longer matches current identity state.", + ) + return verified, preview.expected_revisions + def _build_preview( self, session: Session, @@ -1320,8 +2468,12 @@ class PrivilegedIdentityOperationRepository: user_id: str, ) -> tuple[str, TenantUserModel]: user = session.get(TenantUserModel, user_id) + tenant = session.get(TenantModel, tenant_id) if ( user is None + or tenant is None + or tenant.status != "active" + or tenant.deleted_at is not None or user.tenant_id != tenant_id or user.status != "active" or user.deleted_at is not None @@ -1556,12 +2708,95 @@ def _revision_value(value: datetime) -> str: return _as_utc(value).isoformat() +def _preflight_revision( + session: Session, + expected: PrivilegedIdentityExpectedRevision, +) -> PrivilegedIdentityPreflightRevision: + model_types = { + "identity_account": GlobalAccountModel, + "identity_account_credential": GlobalAccountCredentialModel, + "workspace": TenantModel, + "tenant_user": TenantUserModel, + "workspace_membership": WorkspaceMembershipModel, + "tenant_user_credential": TenantUserCredentialModel, + } + model_type = model_types.get(expected.entity_type) + model = session.get(model_type, expected.entity_id) if model_type is not None else None + current_revision = ( + _revision_value(model.updated_at) + if model is not None and getattr(model, "updated_at", None) is not None + else None + ) + return PrivilegedIdentityPreflightRevision( + entity_type=expected.entity_type, + entity_id=expected.entity_id, + expected_revision=expected.revision, + current_revision=current_revision, + matches=(current_revision == expected.revision), + ) + + +def _preflight_revision_scope( + revisions: list[PrivilegedIdentityExpectedRevision], +) -> list[tuple[str, str]]: + return sorted( + (item.entity_type, item.entity_id) + for item in revisions + ) + + +def _approval_set_revision(session: Session, operation_id: str) -> str: + approvals = session.execute( + select(PrivilegedIdentityOperationApprovalModel) + .where(PrivilegedIdentityOperationApprovalModel.operation_id == operation_id) + .order_by( + PrivilegedIdentityOperationApprovalModel.approver_account_id, + PrivilegedIdentityOperationApprovalModel.id, + ) + ).scalars().all() + return privileged_payload_hash( + [ + { + "id": item.id, + "approver_account_id": item.approver_account_id, + "approver_tenant_id": item.approver_tenant_id, + "approver_user_id": item.approver_user_id, + "decision": item.decision, + "comment": item.comment, + "preview_hash": item.preview_hash, + "permission_revision": item.permission_revision, + "decided_at": _as_utc(item.decided_at).isoformat(), + "expires_at": _as_utc(item.expires_at).isoformat(), + } + for item in approvals + ] + ) + + +def _append_preflight_blocker( + blockers: list[PrivilegedIdentityPreflightBlocker], + code: str, + detail: str, +) -> None: + if all(item.code != code for item in blockers): + blockers.append(PrivilegedIdentityPreflightBlocker(code=code, detail=detail)) + + def _as_utc(value: datetime) -> datetime: if value.tzinfo is None: return value.replace(tzinfo=UTC) return value.astimezone(UTC) +def _is_postgres_lock_timeout(exc: DBAPIError) -> bool: + original = exc.orig + return getattr(original, "sqlstate", None) == "55P03" or getattr( + original, + "pgcode", + None, + ) == "55P03" + + def _safe_trace_id(value: str | None) -> str | None: if not value: return None @@ -1570,6 +2805,163 @@ def _safe_trace_id(value: str | None) -> str | None: return f"trace-fp-{privileged_payload_hash(value)[:24]}" +def _validate_execution_attempt_idempotency_key(value: str) -> None: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{7,159}", value): + raise _error( + 422, + "execution_attempt_idempotency_key_invalid", + "Execution attempt idempotency keys must be 8 to 160 safe characters.", + ) + + +def _execution_attempt_matches( + attempt: PrivilegedIdentityExecutionAttemptModel, + *, + operation_id: str, + request_hash: str, + preview_hash: str, + preflight_hash: str, +) -> bool: + return all( + compare_digest(str(getattr(attempt, field)), value) + for field, value in ( + ("operation_id", operation_id), + ("request_hash", request_hash), + ("preview_hash", preview_hash), + ("preflight_hash", preflight_hash), + ) + ) + + +def _validate_execution_attempt_hash(value: str, *, field: str) -> None: + if not re.fullmatch(r"[0-9a-f]{64}", value): + raise _error( + 422, + "execution_attempt_hash_invalid", + f"Execution attempt {field} must be a lowercase SHA-256 digest.", + ) + + +def _validate_execution_attempt_transition( + status_before: PrivilegedIdentityExecutionAttemptStatus | None, + status_after: PrivilegedIdentityExecutionAttemptStatus, +) -> None: + allowed = _EXECUTION_ATTEMPT_TRANSITIONS.get(status_before) + if allowed is None or status_after not in allowed: + raise _error( + 409, + "execution_attempt_transition_invalid", + "The requested execution attempt status transition is not allowed.", + ) + + +def _validate_execution_attempt_error_code(error_code: str | None) -> None: + if error_code not in _EXECUTION_ATTEMPT_ERROR_CODES: + raise _error( + 422, + "execution_attempt_error_code_invalid", + "Execution attempt failures require a supported sanitized error code.", + ) + + +def _validate_execution_attempt_snapshot( + snapshot: dict[str, object], + *, + expected_status: PrivilegedIdentityExecutionAttemptStatus | None, +) -> None: + if set(snapshot) - _EXECUTION_ATTEMPT_SNAPSHOT_KEYS: + raise _error( + 422, + "execution_attempt_snapshot_invalid", + "Execution attempt snapshots contain a field outside the audit allowlist.", + ) + status = snapshot.get("status") + if "status" in snapshot and status != expected_status: + raise _error( + 422, + "execution_attempt_snapshot_invalid", + "Execution attempt snapshot status does not match the event transition.", + ) + operation_type = snapshot.get("operation_type") + if "operation_type" in snapshot and operation_type not in _TARGET_REF_KEYS: + raise _error( + 422, + "execution_attempt_snapshot_invalid", + "Execution attempt snapshot operation type is invalid.", + ) + for key in ("attempt_no", "resource_count", "step_count", "lock_count"): + value = snapshot.get(key) + if key in snapshot and ( + isinstance(value, bool) or not isinstance(value, int) or value < 0 + ): + raise _error( + 422, + "execution_attempt_snapshot_invalid", + "Execution attempt snapshot counters must be non-negative integers.", + ) + for key in ("lock_plan_hash", "preflight_hash", "preview_hash"): + value = snapshot.get(key) + if key in snapshot: + if not isinstance(value, str): + raise _error( + 422, + "execution_attempt_snapshot_invalid", + "Execution attempt snapshot hashes must be SHA-256 strings.", + ) + _validate_execution_attempt_hash(value, field=key) + for key in ("compensation_required", "retryable"): + value = snapshot.get(key) + if key in snapshot and not isinstance(value, bool): + raise _error( + 422, + "execution_attempt_snapshot_invalid", + "Execution attempt snapshot flags must be boolean.", + ) + result_code = snapshot.get("result_code") + if "result_code" in snapshot and ( + not isinstance(result_code, str) + or not re.fullmatch(r"[a-z][a-z0-9_.-]{0,63}", result_code) + ): + raise _error( + 422, + "execution_attempt_snapshot_invalid", + "Execution attempt result codes must be sanitized stable codes.", + ) + + +def _execution_attempt_item_from_model( + session: Session, + attempt: PrivilegedIdentityExecutionAttemptModel, +) -> PrivilegedIdentityExecutionAttemptItem: + events = session.execute( + select(PrivilegedIdentityExecutionAttemptEventModel) + .where(PrivilegedIdentityExecutionAttemptEventModel.attempt_id == attempt.attempt_id) + .order_by(PrivilegedIdentityExecutionAttemptEventModel.sequence_no) + ).scalars().all() + if not events: + raise _error( + 500, + "execution_attempt_ledger_invalid", + "The execution attempt ledger is missing its prepared event.", + ) + latest = events[-1] + last_error_code = next( + (event.error_code for event in reversed(events) if event.error_code is not None), + None, + ) + return PrivilegedIdentityExecutionAttemptItem( + attempt_id=attempt.attempt_id, + operation_id=attempt.operation_id, + attempt_no=attempt.attempt_no, + status=cast(PrivilegedIdentityExecutionAttemptStatus, latest.status_after), + event_count=len(events), + last_error_code=last_error_code, + trace_id=latest.trace_id or attempt.trace_id, + created_at=_as_utc(attempt.created_at), + latest_event_at=_as_utc(latest.created_at), + ) + + def _validate_event_snapshot(snapshot: dict[str, object]) -> None: allowed_keys = { "exists", diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/privileged_identity_outbox.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/privileged_identity_outbox.py index ad125c57..b9e85ed0 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/privileged_identity_outbox.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/privileged_identity_outbox.py @@ -1,6 +1,5 @@ from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass from datetime import UTC, datetime, timedelta import re @@ -12,6 +11,12 @@ from sqlalchemy.orm import sessionmaker from yuqei_contract_api.persistence.models import PrivilegedIdentityOutboxMessageModel from yuqei_contract_api.privileged_identity import privileged_payload_hash +from yuqei_contract_api.privileged_identity_delivery import ( + PrivilegedIdentityDeliveryContract, + PrivilegedIdentityDeliveryEnvelope, + PrivilegedIdentityDeliveryError, + PrivilegedIdentityDeliveryReceipt, +) @dataclass(frozen=True) @@ -182,13 +187,19 @@ _SQLITE_WORKER_LOCK = Lock() class PrivilegedIdentityOutboxTransport(Protocol): enabled: bool - def deliver(self, message: ClaimedPrivilegedIdentityMessage) -> None: ... + def deliver( + self, + envelope: PrivilegedIdentityDeliveryEnvelope, + ) -> PrivilegedIdentityDeliveryReceipt: ... class DisabledPrivilegedIdentityOutboxTransport: enabled = False - def deliver(self, message: ClaimedPrivilegedIdentityMessage) -> None: + def deliver( + self, + envelope: PrivilegedIdentityDeliveryEnvelope, + ) -> PrivilegedIdentityDeliveryReceipt: raise RuntimeError("Privileged identity outbox transport is disabled.") @@ -201,22 +212,26 @@ class PrivilegedIdentityOutboxWorker: *, worker_id: str, transport: PrivilegedIdentityOutboxTransport | None = None, + delivery_contract: PrivilegedIdentityDeliveryContract | None = None, ) -> None: self._repository = repository self._worker_id = worker_id self._transport = transport or DisabledPrivilegedIdentityOutboxTransport() + self._delivery_contract = delivery_contract def run_once( self, - deliver: Callable[[ClaimedPrivilegedIdentityMessage], None] | None = None, *, batch_size: int = 20, lease_seconds: int = 60, now: datetime | None = None, ) -> int: - if deliver is None and not self._transport.enabled: + if not self._transport.enabled: return 0 - delivery = deliver or self._transport.deliver + if self._delivery_contract is None: + raise RuntimeError( + "Enabled privileged identity outbox transport requires a delivery contract." + ) current = _as_utc(now or datetime.now(UTC)) sqlite_lock_acquired = False if self._repository.dialect_name == "sqlite": @@ -241,7 +256,17 @@ class PrivilegedIdentityOutboxWorker: ) continue try: - delivery(message) + envelope = self._delivery_contract.build(message) + except PrivilegedIdentityDeliveryError as exc: + self._repository.mark_failed( + message.message_id, + worker_id=self._worker_id, + error_code=exc.code, + permanent=True, + ) + continue + try: + receipt = self._transport.deliver(envelope) except Exception: # Transport failures remain retryable and payload-free. self._repository.mark_failed( message.message_id, @@ -249,11 +274,46 @@ class PrivilegedIdentityOutboxWorker: error_code="transport_error", ) continue - if self._repository.mark_delivered( + if not isinstance(receipt, PrivilegedIdentityDeliveryReceipt): + self._repository.mark_failed( + message.message_id, + worker_id=self._worker_id, + error_code="invalid_receipt", + permanent=True, + ) + continue + receipt_matches = ( + receipt.delivery_key == envelope.delivery_key + and receipt.body_digest == envelope.body_digest + ) + if not receipt_matches: + self._repository.mark_failed( + message.message_id, + worker_id=self._worker_id, + error_code="receipt_mismatch", + permanent=True, + ) + continue + if receipt.disposition in {"accepted", "duplicate"}: + if self._repository.mark_delivered( + message.message_id, + worker_id=self._worker_id, + ): + delivered += 1 + continue + if receipt.disposition == "retryable_failure": + self._repository.mark_failed( + message.message_id, + worker_id=self._worker_id, + error_code=receipt.error_code or "delivery_retryable", + ) + continue + self._repository.mark_failed( message.message_id, worker_id=self._worker_id, - ): - delivered += 1 + error_code=receipt.error_code or "delivery_permanent", + permanent=True, + ) return delivered finally: if sqlite_lock_acquired: diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/privileged_identity.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/privileged_identity.py index 3a3a516f..5363855c 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/privileged_identity.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/privileged_identity.py @@ -37,6 +37,32 @@ PrivilegedIdentityOutboxStatus = Literal[ "delivered", "dead_letter", ] +PrivilegedIdentityApprovalSlaStatus = Literal[ + "on_track", + "due_soon", + "breached", +] +PrivilegedIdentityApprovalSlaQueueScope = Literal["window", "all"] +PrivilegedIdentityExecutionAttemptStatus = Literal[ + "prepared", + "running", + "succeeded", + "failed", + "compensation_required", + "compensated", + "compensation_failed", + "aborted", +] +PrivilegedIdentityExecutionAttemptEventType = Literal[ + "attempt_prepared", + "attempt_started", + "attempt_succeeded", + "attempt_failed", + "compensation_required", + "attempt_compensated", + "compensation_failed", + "attempt_aborted", +] class PrivilegedIdentityOperationError(ValueError): @@ -138,6 +164,116 @@ class PrivilegedIdentityOperationPage(BaseModel): page_size: int +class PrivilegedIdentityPreflightBlocker(BaseModel): + code: str + detail: str + + +class PrivilegedIdentityPreflightRevision(BaseModel): + entity_type: str + entity_id: str + expected_revision: str | None = None + current_revision: str | None = None + matches: bool + + +class PrivilegedIdentityPreflightLock(BaseModel): + sequence: int + resource_type: str + resource_id: str + lock_mode: Literal["for_update"] = "for_update" + expected_revision: str | None = None + current_revision: str | None = None + matches: bool + + +class PrivilegedIdentityExecutionPreflight(BaseModel): + operation_id: str + operation_type: PrivilegedIdentityOperationType + stored_status: PrivilegedIdentityOperationStatus + effective_status: PrivilegedIdentityOperationStatus + generated_at: datetime + expires_at: datetime + preview_expires_at: datetime | None = None + preview_hash_verified: bool + valid_approval_count: int + required_approval_count: int = 2 + ready: bool + blockers: list[PrivilegedIdentityPreflightBlocker] = Field(default_factory=list) + revisions: list[PrivilegedIdentityPreflightRevision] = Field(default_factory=list) + lock_plan: list[PrivilegedIdentityPreflightLock] = Field(default_factory=list) + locks_acquired: bool = Field( + default=False, + description=( + "True only when PostgreSQL row locks were held during this verification; " + "the transaction releases them before the result is returned." + ), + ) + executor_enabled: bool = False + + +class PrivilegedIdentityExecutionAttemptItem(BaseModel): + attempt_id: str + operation_id: str + attempt_no: int + status: PrivilegedIdentityExecutionAttemptStatus + event_count: int + last_error_code: str | None = None + trace_id: str | None = None + created_at: datetime + latest_event_at: datetime + + +class PrivilegedIdentityExecutionAttemptPage(BaseModel): + items: list[PrivilegedIdentityExecutionAttemptItem] + total: int + page: int + page_size: int + executor_enabled: bool = False + + +class PrivilegedIdentityApprovalSlaCounts(BaseModel): + on_track: int = 0 + due_soon: int = 0 + breached: int = 0 + + +class PrivilegedIdentityApprovalSlaOverview(BaseModel): + generated_at: datetime + due_soon_seconds: int + window_days: int + total: int + counts: PrivilegedIdentityApprovalSlaCounts + all_pending_total: int + outside_window_pending_count: int + outside_window_breached_count: int + oldest_pending_age_seconds: int | None = None + + +class PrivilegedIdentityApprovalSlaQueueItem(BaseModel): + operation_id: str + operation_type: PrivilegedIdentityOperationType + sla_status: PrivilegedIdentityApprovalSlaStatus + valid_approval_count: int + required_approval_count: int = 2 + remaining_approval_count: int + created_at: datetime + expires_at: datetime + seconds_until_due: int + + +class PrivilegedIdentityApprovalSlaQueuePage(BaseModel): + items: list[PrivilegedIdentityApprovalSlaQueueItem] + total: int + page: int + page_size: int + total_pages: int + generated_at: datetime + due_soon_seconds: int + window_days: int + scope: PrivilegedIdentityApprovalSlaQueueScope + + class PrivilegedIdentityOperationEventItem(BaseModel): event_id: str operation_id: str @@ -274,3 +410,27 @@ def privileged_payload_hash(value: object) -> str: sort_keys=True, ) return sha256(payload.encode("utf-8")).hexdigest() + + +def build_privileged_identity_change_plan(*args: object, **kwargs: object) -> object: + """Lazily expose the internal plan builder without importing it eagerly. + + The lazy import keeps the plan module independent from the persistence + layer while preserving a single discoverable privileged-identity boundary. + """ + + from yuqei_contract_api.privileged_identity_plans import ( + build_privileged_identity_change_plan as _build, + ) + + return _build(*args, **kwargs) + + +def validate_privileged_identity_change_plan(plan: object) -> bool: + """Lazily expose plan hash validation from the identity boundary.""" + + from yuqei_contract_api.privileged_identity_plans import ( + validate_privileged_identity_change_plan as _validate, + ) + + return _validate(plan) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/privileged_identity_delivery.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/privileged_identity_delivery.py new file mode 100644 index 00000000..601bf966 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/privileged_identity_delivery.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from hashlib import sha256 +import hmac +import json +import re +from typing import Literal + + +DeliveryDisposition = Literal[ + "accepted", + "duplicate", + "retryable_failure", + "permanent_failure", +] + +_CONTRACT_VERSION = "privileged-identity-egress.v1" +_DELIVERY_KEY_DOMAIN = b"yuqei:privileged-identity:delivery-key:v1:" +_SIGNATURE_DOMAIN = b"yuqei:privileged-identity:envelope-signature:v1:" +_MIN_SECRET_BYTES = 32 +_MAX_ENVELOPE_BYTES = 8192 +_SAFE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_.:-]{0,127}$") +_EFFECT_TYPES = {"privileged_identity.operation_event"} +_EVENT_TYPES = { + "operation_submitted", + "approval_recorded", + "operation_rejected", + "operation_expired", +} +_OPERATION_TYPES = { + "account_merge", + "account_split", + "membership_recovery", + "platform_admin_grant", + "platform_admin_revoke", + "support_access_grant", +} +_OPERATION_STATUSES = { + "pending_approval", + "approved", + "rejected", + "cancelled", + "expired", +} + + +class PrivilegedIdentityDeliveryError(ValueError): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +class RetryablePrivilegedIdentityDeliveryError(RuntimeError): + """Reference consumers may raise this when a downstream dependency can retry.""" + + +@dataclass(frozen=True) +class PrivilegedIdentityDeliveryEnvelope: + contract_version: str + key_id: str + delivery_key: str + effect_type: str + event_type: str + operation_type: str + status_before: str | None + status_after: str + occurred_at: str + body_digest: str + signature: str + + def _body(self) -> dict[str, object]: + return { + "contract_version": self.contract_version, + "key_id": self.key_id, + "delivery_key": self.delivery_key, + "effect_type": self.effect_type, + "event_type": self.event_type, + "operation_type": self.operation_type, + "status_before": self.status_before, + "status_after": self.status_after, + "occurred_at": self.occurred_at, + } + + def canonical_body(self) -> bytes: + return _canonical_json(self._body()) + + def canonical_document(self) -> bytes: + return _canonical_json( + { + **self._body(), + "body_digest": self.body_digest, + "signature": self.signature, + } + ) + + +@dataclass(frozen=True) +class PrivilegedIdentityDeliveryReceipt: + delivery_key: str + body_digest: str + disposition: DeliveryDisposition + error_code: str | None = None + + +class PrivilegedIdentityDeliveryContract: + """Builds a minimal, signed envelope without exposing internal outbox data.""" + + def __init__( + self, + *, + key_id: str, + signing_secret: bytes | str, + delivery_key_secret: bytes | str, + ) -> None: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,63}", key_id): + raise PrivilegedIdentityDeliveryError("invalid_key_id", "Delivery key id is invalid.") + signing_secret_bytes = _secret_bytes(signing_secret) + delivery_key_secret_bytes = _secret_bytes(delivery_key_secret) + if len(signing_secret_bytes) < _MIN_SECRET_BYTES: + raise PrivilegedIdentityDeliveryError( + "signing_key_too_short", + "Delivery signing secret must be at least 32 bytes.", + ) + if len(delivery_key_secret_bytes) < _MIN_SECRET_BYTES: + raise PrivilegedIdentityDeliveryError( + "delivery_key_too_short", + "Delivery idempotency secret must be at least 32 bytes.", + ) + self.key_id = key_id + self._signing_secret = signing_secret_bytes + self._delivery_key_secret = delivery_key_secret_bytes + + def build(self, message: object) -> PrivilegedIdentityDeliveryEnvelope: + payload = getattr(message, "payload", None) + if not isinstance(payload, dict): + raise PrivilegedIdentityDeliveryError("invalid_message", "Outbox payload is invalid.") + effect_type = _required_choice( + getattr(message, "effect_type", None), + _EFFECT_TYPES, + "invalid_effect_type", + ) + event_type = _required_choice( + payload.get("event_type"), + _EVENT_TYPES, + "invalid_event_type", + ) + operation_type = _required_choice( + payload.get("operation_type"), + _OPERATION_TYPES, + "invalid_operation_type", + ) + status_after = _required_choice( + payload.get("status_after"), + _OPERATION_STATUSES, + "invalid_status", + ) + status_before = payload.get("status_before") + if status_before is not None: + status_before = _required_choice( + status_before, + _OPERATION_STATUSES, + "invalid_status", + ) + occurred_at = _required_timestamp(payload.get("occurred_at")) + internal_key = _required_text( + getattr(message, "idempotency_key", None), + "invalid_idempotency_key", + ) + delivery_key = hmac.new( + self._delivery_key_secret, + _DELIVERY_KEY_DOMAIN + internal_key.encode("utf-8"), + sha256, + ).hexdigest() + body = { + "contract_version": _CONTRACT_VERSION, + "key_id": self.key_id, + "delivery_key": delivery_key, + "effect_type": effect_type, + "event_type": event_type, + "operation_type": operation_type, + "status_before": status_before, + "status_after": status_after, + "occurred_at": occurred_at, + } + body_bytes = _canonical_json(body) + body_digest = sha256(body_bytes).hexdigest() + signature = hmac.new( + self._signing_secret, + _SIGNATURE_DOMAIN + body_bytes, + sha256, + ).hexdigest() + envelope = PrivilegedIdentityDeliveryEnvelope( + contract_version=_CONTRACT_VERSION, + key_id=self.key_id, + delivery_key=delivery_key, + effect_type=effect_type, + event_type=event_type, + operation_type=operation_type, + status_before=status_before, + status_after=status_after, + occurred_at=occurred_at, + body_digest=body_digest, + signature=signature, + ) + if len(envelope.canonical_document()) > _MAX_ENVELOPE_BYTES: + raise PrivilegedIdentityDeliveryError("envelope_too_large", "Delivery envelope is too large.") + return envelope + + def verify(self, envelope: PrivilegedIdentityDeliveryEnvelope) -> bool: + if envelope.key_id != self.key_id or envelope.contract_version != _CONTRACT_VERSION: + return False + body = envelope.canonical_body() + expected_digest = sha256(body).hexdigest() + expected_signature = hmac.new( + self._signing_secret, + _SIGNATURE_DOMAIN + body, + sha256, + ).hexdigest() + return hmac.compare_digest(envelope.body_digest, expected_digest) and hmac.compare_digest( + envelope.signature, + expected_signature, + ) + + +class InMemoryPrivilegedIdentityConsumer: + """Reference consumer for contract tests; production consumers must persist receipts atomically.""" + + enabled = True + + def __init__( + self, + consumer_id: str, + contracts: Mapping[str, PrivilegedIdentityDeliveryContract], + *, + apply: Callable[[PrivilegedIdentityDeliveryEnvelope], None] | None = None, + ) -> None: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", consumer_id): + raise PrivilegedIdentityDeliveryError( + "invalid_consumer_id", + "Delivery consumer id is invalid.", + ) + self._consumer_id = consumer_id + self._contracts = dict(contracts) + self._apply = apply or (lambda _envelope: None) + self._receipts: dict[tuple[str, str], str] = {} + + @property + def receipt_count(self) -> int: + return len(self._receipts) + + def deliver(self, envelope: PrivilegedIdentityDeliveryEnvelope) -> PrivilegedIdentityDeliveryReceipt: + if envelope.contract_version != _CONTRACT_VERSION: + return _permanent_receipt(envelope, "unsupported_schema_major") + if len(envelope.canonical_document()) > _MAX_ENVELOPE_BYTES: + return _permanent_receipt(envelope, "envelope_too_large") + contract = self._contracts.get(envelope.key_id) + if contract is None or not contract.verify(envelope): + return _permanent_receipt(envelope, "invalid_signature") + receipt_key = (self._consumer_id, envelope.delivery_key) + existing_digest = self._receipts.get(receipt_key) + if existing_digest is not None: + if hmac.compare_digest(existing_digest, envelope.body_digest): + return PrivilegedIdentityDeliveryReceipt( + delivery_key=envelope.delivery_key, + body_digest=envelope.body_digest, + disposition="duplicate", + ) + return _permanent_receipt(envelope, "idempotency_conflict") + try: + self._apply(envelope) + except RetryablePrivilegedIdentityDeliveryError: + return _retryable_receipt(envelope, "consumer_unavailable") + except Exception: + return _permanent_receipt(envelope, "consumer_error") + self._receipts[receipt_key] = envelope.body_digest + return PrivilegedIdentityDeliveryReceipt( + delivery_key=envelope.delivery_key, + body_digest=envelope.body_digest, + disposition="accepted", + ) + + +def _canonical_json(value: Mapping[str, object]) -> bytes: + return json.dumps(value, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode("utf-8") + + +def _required_text(value: object, code: str) -> str: + if not isinstance(value, str) or not value.strip() or len(value) > 256: + raise PrivilegedIdentityDeliveryError(code, "Delivery envelope field is invalid.") + return value.strip() + + +def _required_choice(value: object, choices: set[str], code: str) -> str: + candidate = _required_text(value, code) + if candidate not in choices: + raise PrivilegedIdentityDeliveryError(code, "Delivery envelope value is unsupported.") + return candidate + + +def _required_timestamp(value: object) -> str: + candidate = _required_text(value, "invalid_occurred_at") + try: + parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00")) + except ValueError as exc: + raise PrivilegedIdentityDeliveryError( + "invalid_occurred_at", + "Delivery occurrence time is invalid.", + ) from exc + if parsed.tzinfo is None: + raise PrivilegedIdentityDeliveryError( + "invalid_occurred_at", + "Delivery occurrence time must include a timezone.", + ) + return parsed.astimezone(UTC).isoformat() + + +def _secret_bytes(value: bytes | str) -> bytes: + return value.encode("utf-8") if isinstance(value, str) else bytes(value) + + +def _safe_error_code(value: str) -> str: + candidate = value.strip().lower()[:128] + return candidate if _SAFE_CODE_PATTERN.fullmatch(candidate) else "delivery_error" + + +def _retryable_receipt(envelope: PrivilegedIdentityDeliveryEnvelope, code: str) -> PrivilegedIdentityDeliveryReceipt: + return PrivilegedIdentityDeliveryReceipt( + delivery_key=envelope.delivery_key, + body_digest=envelope.body_digest, + disposition="retryable_failure", + error_code=_safe_error_code(code), + ) + + +def _permanent_receipt(envelope: PrivilegedIdentityDeliveryEnvelope, code: str) -> PrivilegedIdentityDeliveryReceipt: + return PrivilegedIdentityDeliveryReceipt( + delivery_key=envelope.delivery_key, + body_digest=envelope.body_digest, + disposition="permanent_failure", + error_code=_safe_error_code(code), + ) + + +__all__ = [ + "DeliveryDisposition", + "InMemoryPrivilegedIdentityConsumer", + "PrivilegedIdentityDeliveryContract", + "PrivilegedIdentityDeliveryEnvelope", + "PrivilegedIdentityDeliveryError", + "PrivilegedIdentityDeliveryReceipt", + "RetryablePrivilegedIdentityDeliveryError", +] diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/privileged_identity_plans.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/privileged_identity_plans.py new file mode 100644 index 00000000..ff7b186a --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/privileged_identity_plans.py @@ -0,0 +1,549 @@ +"""Deterministic, non-executing plans for privileged identity operations. + +The objects in this module are deliberately descriptive. Building a plan does +not open a transaction, mutate identity state, append an event, or enqueue an +outbox message. It is the boundary used by a future executor, while the +executor itself remains disabled. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any, Literal, cast + +from pydantic import BaseModel, ConfigDict, Field + +from yuqei_contract_api.privileged_identity import ( + PrivilegedIdentityExpectedRevision, + PrivilegedIdentityOperationType, + PrivilegedIdentityPreflightLock, + normalize_privileged_json, + privileged_payload_hash, +) + + +class PrivilegedIdentityPlanError(ValueError): + """Stable validation failure for an internal plan request.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +class PrivilegedIdentityPlanTargetFingerprint(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + key: str = Field(min_length=1, max_length=64) + value_hash: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class PrivilegedIdentityPlanStep(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + sequence: int = Field(ge=1) + step_id: str = Field(min_length=1, max_length=96) + action: str = Field(min_length=1, max_length=96) + target_ref_keys: tuple[str, ...] = () + resource_types: tuple[str, ...] = () + + +class PrivilegedIdentityChangePlan(BaseModel): + """A stable forward plan plus its reverse compensation plan. + + Raw target references are intentionally absent. The fingerprints allow a + later caller to prove that the same request was planned without putting an + email, token, credential, or support subject into an audit snapshot. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + operation_id: str = Field(min_length=1, max_length=128) + operation_type: PrivilegedIdentityOperationType + target_ref_fingerprints: tuple[PrivilegedIdentityPlanTargetFingerprint, ...] + expected_revisions: tuple[PrivilegedIdentityExpectedRevision, ...] + lock_plan: tuple[PrivilegedIdentityPreflightLock, ...] + steps: tuple[PrivilegedIdentityPlanStep, ...] + compensation_steps: tuple[PrivilegedIdentityPlanStep, ...] + plan_hash: str = Field(pattern=r"^[0-9a-f]{64}$") + compensation_plan_hash: str = Field(pattern=r"^[0-9a-f]{64}$") + executor_enabled: Literal[False] = False + + @property + def compensation_hash(self) -> str: + """Compatibility spelling used by the governance documents.""" + + return self.compensation_plan_hash + + +# Support access remains deliberately outside the execution-plan boundary. It +# is retained in the operation model for read-only preview/approval history, +# but no mutation or transport plan may be generated for it yet. +_PLAN_OPERATION_TYPES: frozenset[PrivilegedIdentityOperationType] = frozenset( + { + "account_merge", + "account_split", + "membership_recovery", + "platform_admin_grant", + "platform_admin_revoke", + } +) +_TARGET_REF_KEYS: dict[PrivilegedIdentityOperationType, frozenset[str]] = { + "account_merge": frozenset({"source_account_id", "target_account_id"}), + "account_split": frozenset( + {"source_account_id", "destination_account_id", "membership_partition"} + ), + "membership_recovery": frozenset( + {"account_id", "tenant_id", "tenant_user_id", "recovery_type"} + ), + "platform_admin_grant": frozenset({"target_tenant_id", "target_user_id"}), + "platform_admin_revoke": frozenset({"target_tenant_id", "target_user_id"}), + "support_access_grant": frozenset( + { + "support_account_id", + "tenant_id", + "permission_scopes", + "expires_at", + "subject_reference", + } + ), +} +_LOCK_RANKS: dict[str, int] = { + "privileged_operation": 10, + "privileged_approval_set": 20, + "identity_account": 100, + "workspace": 200, + "tenant_user": 300, + "workspace_membership": 400, + "identity_account_credential": 500, + "tenant_user_credential": 600, +} +_EXPECTED_REVISION_TYPES = frozenset( + { + "identity_account", + "workspace", + "tenant_user", + "workspace_membership", + "identity_account_credential", + "tenant_user_credential", + } +) +_SENSITIVE_KEY_PARTS = frozenset( + { + "password", + "secret", + "token", + "credential", + "authorization", + "cookie", + "email", + "phone", + "private_key", + } +) + + +# Every action is symbolic and deterministic. It names only the allowed +# target keys and resource classes; it does not embed target values. +_FORWARD_STEPS: dict[PrivilegedIdentityOperationType, tuple[tuple[str, str, tuple[str, ...], tuple[str, ...]], ...]] = { + "account_merge": ( + ( + "merge.accounts", + "merge_identity_accounts", + ("source_account_id", "target_account_id"), + ("identity_account",), + ), + ( + "merge.memberships", + "reassign_workspace_memberships", + ("source_account_id", "target_account_id"), + ("workspace_membership", "tenant_user", "workspace"), + ), + ( + "merge.credentials", + "reconcile_global_credentials", + ("source_account_id", "target_account_id"), + ("identity_account_credential",), + ), + ), + "account_split": ( + ( + "split.partition", + "apply_membership_partition", + ("source_account_id", "destination_account_id", "membership_partition"), + ("workspace_membership", "tenant_user", "workspace"), + ), + ( + "split.accounts", + "reconcile_split_identity_accounts", + ("source_account_id", "destination_account_id"), + ("identity_account",), + ), + ( + "split.credentials", + "reconcile_split_credentials", + ("source_account_id", "destination_account_id"), + ("identity_account_credential",), + ), + ), + "membership_recovery": ( + ( + "recovery.membership", + "recover_workspace_membership", + ("account_id", "tenant_id", "tenant_user_id", "recovery_type"), + ("workspace_membership", "tenant_user", "identity_account", "workspace"), + ), + ( + "recovery.consistency", + "restore_workspace_owner_consistency", + ("tenant_id", "tenant_user_id"), + ("workspace", "tenant_user"), + ), + ), + "platform_admin_grant": ( + ( + "platform-admin.grant", + "grant_platform_admin_permission", + ("target_tenant_id", "target_user_id"), + ("tenant_user", "workspace_membership", "identity_account"), + ), + ), + "platform_admin_revoke": ( + ( + "platform-admin.revoke", + "revoke_platform_admin_permission", + ("target_tenant_id", "target_user_id"), + ("tenant_user", "workspace_membership", "identity_account"), + ), + ), +} +_COMPENSATION_ACTIONS: dict[str, str] = { + "merge.accounts": "restore_identity_accounts", + "merge.memberships": "restore_workspace_memberships", + "merge.credentials": "restore_global_credentials", + "split.partition": "restore_membership_partition", + "split.accounts": "restore_split_identity_accounts", + "split.credentials": "restore_split_credentials", + "recovery.membership": "restore_workspace_membership_state", + "recovery.consistency": "restore_workspace_owner_consistency", + "platform-admin.grant": "revoke_platform_admin_permission", + "platform-admin.revoke": "grant_platform_admin_permission", +} + + +def build_privileged_identity_change_plan( + operation_type: PrivilegedIdentityOperationType | str, + target_refs: Mapping[str, object], + expected_revisions: Sequence[PrivilegedIdentityExpectedRevision | Mapping[str, object]], + lock_plan: Sequence[PrivilegedIdentityPreflightLock | Mapping[str, object]], + *, + operation_id: str | None = None, +) -> PrivilegedIdentityChangePlan: + """Build and hash a deterministic, non-executing identity change plan. + + Input sequences may arrive in any order; their canonical representations + are sorted before both plan hashes are computed. Any blocker represented by + an invalid/stale lock plan fails closed instead of producing an execution + candidate. + """ + + if operation_type not in _PLAN_OPERATION_TYPES: + if operation_type == "support_access_grant": + raise PrivilegedIdentityPlanError( + "support_access_plan_disabled", + "Support access plans remain disabled until their transport and expiry controls are approved.", + ) + raise PrivilegedIdentityPlanError("unknown_operation_type", "The identity operation type is not supported.") + operation = cast(PrivilegedIdentityOperationType, operation_type) + refs = _normalize_target_refs(operation, target_refs) + revisions = _normalize_expected_revisions(expected_revisions) + locks, inferred_operation_id = _normalize_lock_plan(lock_plan, revisions) + if operation_id is None: + operation_id = inferred_operation_id + elif operation_id != inferred_operation_id: + raise PrivilegedIdentityPlanError( + "operation_id_mismatch", + "The operation ID does not match the privileged-operation lock row.", + ) + if not operation_id: + raise PrivilegedIdentityPlanError("operation_id_missing", "A privileged-operation lock row is required.") + + fingerprints = tuple( + PrivilegedIdentityPlanTargetFingerprint( + key=key, + value_hash=privileged_payload_hash(refs[key]), + ) + for key in sorted(refs) + ) + steps = tuple( + PrivilegedIdentityPlanStep( + sequence=sequence, + step_id=step_id, + action=action, + target_ref_keys=target_keys, + resource_types=resource_types, + ) + for sequence, (step_id, action, target_keys, resource_types) in enumerate( + _FORWARD_STEPS[operation], start=1 + ) + ) + compensation_steps = tuple( + PrivilegedIdentityPlanStep( + sequence=sequence, + step_id=f"compensate.{step.step_id}", + action=_COMPENSATION_ACTIONS[step.step_id], + target_ref_keys=step.target_ref_keys, + resource_types=step.resource_types, + ) + for sequence, step in enumerate(reversed(steps), start=1) + ) + canonical_base: dict[str, Any] = { + "operation_id": operation_id, + "operation_type": operation, + "target_ref_fingerprints": [item.model_dump(mode="json") for item in fingerprints], + "expected_revisions": [item.model_dump(mode="json") for item in revisions], + "lock_plan": [item.model_dump(mode="json") for item in locks], + "steps": [item.model_dump(mode="json") for item in steps], + } + plan_hash = privileged_payload_hash({"kind": "identity_change_plan", **canonical_base}) + compensation_hash = privileged_payload_hash( + { + "kind": "identity_compensation_plan", + "for_plan_hash": plan_hash, + "operation_id": operation_id, + "operation_type": operation, + "steps": [item.model_dump(mode="json") for item in compensation_steps], + } + ) + return PrivilegedIdentityChangePlan( + operation_id=operation_id, + operation_type=operation, + target_ref_fingerprints=fingerprints, + expected_revisions=tuple(revisions), + lock_plan=tuple(locks), + steps=steps, + compensation_steps=compensation_steps, + plan_hash=plan_hash, + compensation_plan_hash=compensation_hash, + ) + + +def validate_privileged_identity_change_plan(plan: PrivilegedIdentityChangePlan) -> bool: + """Recompute both hashes and verify immutable ordering invariants.""" + + try: + rebuilt = _rebuild_hashes(plan) + except (PrivilegedIdentityPlanError, TypeError, ValueError): + return False + return rebuilt == (plan.plan_hash, plan.compensation_plan_hash) + + +def _rebuild_hashes(plan: PrivilegedIdentityChangePlan) -> tuple[str, str]: + if plan.operation_type not in _PLAN_OPERATION_TYPES: + raise PrivilegedIdentityPlanError("unknown_operation_type", "The identity operation type is not supported.") + fingerprint_keys = [item.key for item in plan.target_ref_fingerprints] + if fingerprint_keys != sorted(_TARGET_REF_KEYS[plan.operation_type]): + raise PrivilegedIdentityPlanError( + "target_ref_fingerprints_invalid", + "Target reference fingerprints are not canonical.", + ) + canonical_revisions = _normalize_expected_revisions(plan.expected_revisions) + if tuple(plan.expected_revisions) != canonical_revisions: + raise PrivilegedIdentityPlanError( + "expected_revisions_invalid", + "Expected revisions are not in canonical order.", + ) + locks, inferred_id = _normalize_lock_plan(plan.lock_plan, canonical_revisions) + if inferred_id != plan.operation_id: + raise PrivilegedIdentityPlanError("operation_id_mismatch", "Plan operation lock does not match operation ID.") + expected_steps = tuple( + PrivilegedIdentityPlanStep( + sequence=sequence, + step_id=step_id, + action=action, + target_ref_keys=target_keys, + resource_types=resource_types, + ) + for sequence, (step_id, action, target_keys, resource_types) in enumerate( + _FORWARD_STEPS[plan.operation_type], start=1 + ) + ) + if tuple(plan.steps) != expected_steps: + raise PrivilegedIdentityPlanError("steps_invalid", "The plan steps are not canonical.") + expected_compensation = tuple( + PrivilegedIdentityPlanStep( + sequence=sequence, + step_id=f"compensate.{step.step_id}", + action=_COMPENSATION_ACTIONS[step.step_id], + target_ref_keys=step.target_ref_keys, + resource_types=step.resource_types, + ) + for sequence, step in enumerate(reversed(expected_steps), start=1) + ) + if tuple(plan.compensation_steps) != expected_compensation: + raise PrivilegedIdentityPlanError("compensation_steps_invalid", "The compensation steps are not canonical.") + canonical_base = { + "operation_id": plan.operation_id, + "operation_type": plan.operation_type, + "target_ref_fingerprints": [item.model_dump(mode="json") for item in plan.target_ref_fingerprints], + "expected_revisions": [item.model_dump(mode="json") for item in plan.expected_revisions], + "lock_plan": [item.model_dump(mode="json") for item in locks], + "steps": [item.model_dump(mode="json") for item in expected_steps], + } + forward_hash = privileged_payload_hash({"kind": "identity_change_plan", **canonical_base}) + compensation_hash = privileged_payload_hash( + { + "kind": "identity_compensation_plan", + "for_plan_hash": forward_hash, + "operation_id": plan.operation_id, + "operation_type": plan.operation_type, + "steps": [item.model_dump(mode="json") for item in expected_compensation], + } + ) + return forward_hash, compensation_hash + + +def _normalize_target_refs(operation: PrivilegedIdentityOperationType, target_refs: Mapping[str, object]) -> dict[str, object]: + if not isinstance(target_refs, Mapping): + raise PrivilegedIdentityPlanError("invalid_target_refs", "Target references must be an object.") + try: + normalized = normalize_privileged_json(dict(target_refs)) + except Exception as exc: + raise PrivilegedIdentityPlanError("invalid_target_refs", "Target references contain unsupported values.") from exc + if not isinstance(normalized, dict): + raise PrivilegedIdentityPlanError("invalid_target_refs", "Target references must be an object.") + refs = cast(dict[str, object], normalized) + expected = _TARGET_REF_KEYS[operation] + keys = set(refs) + unknown = sorted(keys - expected) + missing = sorted(expected - keys) + if unknown: + raise PrivilegedIdentityPlanError("unexpected_target_reference", "Unknown target reference fields are not allowed.") + if missing: + raise PrivilegedIdentityPlanError("incomplete_target_refs", "All target reference fields are required.") + _reject_sensitive_fields(refs) + if operation in {"account_merge", "platform_admin_grant", "platform_admin_revoke"}: + for value in refs.values(): + if not isinstance(value, str) or not value.strip(): + raise PrivilegedIdentityPlanError("invalid_target_reference", "Identity target references must be non-empty IDs.") + if operation == "account_split": + _require_id(refs, "source_account_id") + _require_id(refs, "destination_account_id") + if refs["source_account_id"] == refs["destination_account_id"]: + raise PrivilegedIdentityPlanError("same_account", "Source and destination accounts must differ.") + partition = refs.get("membership_partition") + if not isinstance(partition, dict) or not partition: + raise PrivilegedIdentityPlanError("incomplete_membership_partition", "Membership partition must be non-empty.") + if any(not isinstance(k, str) or not k.strip() for k in partition) or any( + not isinstance(v, str) or not v.strip() for v in partition.values() + ): + raise PrivilegedIdentityPlanError("invalid_membership_partition", "Membership partition values must be IDs.") + if any(v not in {refs["source_account_id"], refs["destination_account_id"]} for v in partition.values()): + raise PrivilegedIdentityPlanError("invalid_membership_partition", "Membership partition destination is invalid.") + if operation == "membership_recovery": + for key in ("account_id", "tenant_id", "tenant_user_id", "recovery_type"): + _require_id(refs, key) + if refs["recovery_type"] not in {"reactivate", "relink", "recreate"}: + raise PrivilegedIdentityPlanError("invalid_recovery_type", "Unsupported membership recovery type.") + return refs + + +def _reject_sensitive_fields(value: object) -> None: + if isinstance(value, dict): + for key, nested in value.items(): + lowered = key.lower() + if any(part in lowered for part in _SENSITIVE_KEY_PARTS): + raise PrivilegedIdentityPlanError("sensitive_target_reference", "Sensitive target fields are not allowed in a plan.") + _reject_sensitive_fields(nested) + elif isinstance(value, list): + for nested in value: + _reject_sensitive_fields(nested) + + +def _require_id(refs: Mapping[str, object], key: str) -> str: + value = refs.get(key) + if not isinstance(value, str) or not value.strip(): + raise PrivilegedIdentityPlanError("invalid_target_reference", f"Target reference '{key}' must be a non-empty ID.") + return value.strip() + + +def _normalize_expected_revisions( + revisions: Sequence[PrivilegedIdentityExpectedRevision | Mapping[str, object]], +) -> tuple[PrivilegedIdentityExpectedRevision, ...]: + if not isinstance(revisions, Sequence) or isinstance(revisions, (str, bytes)) or not revisions: + raise PrivilegedIdentityPlanError("expected_revisions_missing", "Expected revisions are required.") + models: list[PrivilegedIdentityExpectedRevision] = [] + seen: set[tuple[str, str]] = set() + for raw in revisions: + try: + model = raw if isinstance(raw, PrivilegedIdentityExpectedRevision) else PrivilegedIdentityExpectedRevision.model_validate(raw) + except (TypeError, ValueError) as exc: + raise PrivilegedIdentityPlanError("expected_revisions_invalid", "Expected revision data is invalid.") from exc + if model.entity_type not in _EXPECTED_REVISION_TYPES or not all( + isinstance(value, str) and value.strip() for value in (model.entity_type, model.entity_id, model.revision) + ): + raise PrivilegedIdentityPlanError("expected_revisions_invalid", "Expected revision data is invalid.") + key = (model.entity_type, model.entity_id) + if key in seen: + raise PrivilegedIdentityPlanError("expected_revisions_invalid", "Expected revisions contain duplicate resources.") + seen.add(key) + models.append(model) + return tuple(sorted(models, key=lambda item: (_LOCK_RANKS[item.entity_type], item.entity_type, item.entity_id))) + + +def _normalize_lock_plan( + lock_plan: Sequence[PrivilegedIdentityPreflightLock | Mapping[str, object]], + revisions: Sequence[PrivilegedIdentityExpectedRevision], +) -> tuple[tuple[PrivilegedIdentityPreflightLock, ...], str]: + if not isinstance(lock_plan, Sequence) or isinstance(lock_plan, (str, bytes)) or not lock_plan: + raise PrivilegedIdentityPlanError("lock_plan_missing", "A fixed lock plan is required.") + models: list[PrivilegedIdentityPreflightLock] = [] + for raw in lock_plan: + try: + model = raw if isinstance(raw, PrivilegedIdentityPreflightLock) else PrivilegedIdentityPreflightLock.model_validate(raw) + except (TypeError, ValueError) as exc: + raise PrivilegedIdentityPlanError("lock_plan_invalid", "Lock plan data is invalid.") from exc + if model.resource_type not in _LOCK_RANKS or not model.resource_id.strip(): + raise PrivilegedIdentityPlanError("lock_plan_invalid", "Lock plan contains an unsupported resource.") + if model.lock_mode != "for_update": + raise PrivilegedIdentityPlanError("lock_plan_invalid", "Identity plans require for_update locks.") + if not model.matches or ( + model.expected_revision is not None + and model.current_revision is not None + and model.expected_revision != model.current_revision + ): + raise PrivilegedIdentityPlanError("lock_plan_not_ready", "The fixed lock plan contains a stale or blocked resource.") + models.append(model) + canonical = sorted(models, key=lambda item: (_LOCK_RANKS[item.resource_type], item.resource_type, item.resource_id)) + if any(item.sequence != index for index, item in enumerate(canonical, start=1)): + raise PrivilegedIdentityPlanError("lock_plan_order_invalid", "Lock plan sequence is not canonical.") + if len(canonical) < 2 or canonical[0].resource_type != "privileged_operation" or canonical[1].resource_type != "privileged_approval_set": + raise PrivilegedIdentityPlanError("lock_plan_scope_invalid", "Operation and approval-set locks must be first.") + if len({(item.resource_type, item.resource_id) for item in canonical}) != len(canonical): + raise PrivilegedIdentityPlanError("lock_plan_invalid", "Lock plan contains duplicate resources.") + expected_scope = {(item.entity_type, item.entity_id) for item in revisions} + actual_scope = { + (item.resource_type, item.resource_id) + for item in canonical + if item.resource_type in _EXPECTED_REVISION_TYPES + } + if actual_scope != expected_scope: + raise PrivilegedIdentityPlanError("lock_plan_scope_invalid", "Lock plan does not match expected revisions.") + return tuple(canonical), canonical[0].resource_id + + +# Explicit aliases keep the internal API discoverable without exposing a route. +build_deterministic_privileged_identity_plan = build_privileged_identity_change_plan +validate_deterministic_privileged_identity_plan = validate_privileged_identity_change_plan + + +__all__ = [ + "PrivilegedIdentityChangePlan", + "PrivilegedIdentityPlanError", + "PrivilegedIdentityPlanStep", + "PrivilegedIdentityPlanTargetFingerprint", + "build_deterministic_privileged_identity_plan", + "build_privileged_identity_change_plan", + "validate_deterministic_privileged_identity_plan", + "validate_privileged_identity_change_plan", +] diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/privileged_support_access.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/privileged_support_access.py new file mode 100644 index 00000000..a1824371 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/privileged_support_access.py @@ -0,0 +1,443 @@ +"""Validation-only policy for future privileged support access. + +This module never grants access or creates a session. It freezes the online +checks, automatic expiry and emergency-revocation semantics that a future +enforcement point must satisfy before support access can be enabled. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from yuqei_contract_api.privileged_identity import ( + PrivilegedIdentityExpectedRevision, + normalize_privileged_json, + privileged_payload_hash, +) + + +SUPPORT_ACCESS_ALLOWED_SCOPES = frozenset( + { + "audit.read", + "contract.comment", + "contract.read", + "review.read", + "template.read", + } +) +_SUPPORT_ACCESS_MAX_TTL = timedelta(hours=24) +_SAFE_REVOCATION_CODES = frozenset( + { + "security_incident", + "operator_request", + "approval_revoked", + "scope_violation", + "account_compromise", + } +) + + +class PrivilegedSupportAccessError(ValueError): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +class PrivilegedSupportAccessGrant(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + grant_id: str = Field(min_length=1, max_length=96) + operation_id: str = Field(min_length=1, max_length=64) + support_account_id: str = Field(min_length=1, max_length=64) + tenant_id: str = Field(min_length=1, max_length=64) + permission_scopes: tuple[str, ...] + subject_reference_hash: str = Field(pattern=r"^[0-9a-f]{64}$") + account_revision: str = Field(min_length=1, max_length=128) + workspace_revision: str = Field(min_length=1, max_length=128) + issued_at: datetime + expires_at: datetime + revoked_at: datetime | None = None + revocation_code: str | None = None + revocation_hash: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + policy_hash: str = Field(pattern=r"^[0-9a-f]{64}$") + access_enabled: Literal[False] = False + + +class PrivilegedSupportAccessCheck(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + grant_id: str + checked_at: datetime + effective_status: Literal["validation_only", "expired", "revoked", "blocked"] + policy_satisfied: bool + access_granted: Literal[False] = False + code: str + seconds_until_expiry: int + + +def build_privileged_support_access_grant( + *, + operation_id: str, + target_refs: dict[str, object], + expected_revisions: list[PrivilegedIdentityExpectedRevision | dict[str, object]], + operation_status: str, + valid_approval_count: int, + preview_hash_verified: bool, + issued_at: datetime, +) -> PrivilegedSupportAccessGrant: + """Build validation material from an approved support-access operation.""" + + if operation_status != "approved": + raise PrivilegedSupportAccessError( + "support_access_operation_not_approved", + "Support access validation requires an approved operation.", + ) + if valid_approval_count < 2 or not preview_hash_verified: + raise PrivilegedSupportAccessError( + "support_access_approval_not_current", + "Support access approvals and preview material must still be valid.", + ) + normalized_operation_id = operation_id.strip() + if not normalized_operation_id or len(normalized_operation_id) > 64: + raise PrivilegedSupportAccessError( + "support_access_operation_invalid", + "Support access operation ID is required.", + ) + try: + normalized = normalize_privileged_json(target_refs) + except (TypeError, ValueError) as exc: + raise PrivilegedSupportAccessError( + "support_access_target_invalid", + "Support access target references contain unsupported values.", + ) from exc + if not isinstance(normalized, dict): + raise PrivilegedSupportAccessError( + "support_access_target_invalid", + "Support access target references must be an object.", + ) + required = { + "support_account_id", + "tenant_id", + "permission_scopes", + "expires_at", + "subject_reference", + } + if set(normalized) != required: + raise PrivilegedSupportAccessError( + "support_access_target_invalid", + "Support access target references must match the approved schema.", + ) + support_account_id = _required_text(normalized, "support_account_id") + tenant_id = _required_text(normalized, "tenant_id") + subject_reference = _required_text(normalized, "subject_reference") + raw_scopes = normalized.get("permission_scopes") + if not isinstance(raw_scopes, list) or not raw_scopes: + raise PrivilegedSupportAccessError( + "support_access_scope_invalid", + "At least one support access scope is required.", + ) + scopes = tuple(sorted(set(_required_scope(item) for item in raw_scopes))) + if not set(scopes) <= SUPPORT_ACCESS_ALLOWED_SCOPES: + raise PrivilegedSupportAccessError( + "support_access_scope_invalid", + "One or more support access scopes are not allowed.", + ) + normalized_issued_at = _as_utc(issued_at) + expires_at = _parse_datetime(normalized.get("expires_at"), "expires_at") + if expires_at <= normalized_issued_at or expires_at - normalized_issued_at > _SUPPORT_ACCESS_MAX_TTL: + raise PrivilegedSupportAccessError( + "support_access_expiry_invalid", + "Support access must expire within 24 hours after issuance.", + ) + revisions = _revision_map(expected_revisions) + expected_revision_scope = { + ("identity_account", support_account_id), + ("workspace", tenant_id), + } + account_revision = revisions.get(("identity_account", support_account_id)) + workspace_revision = revisions.get(("workspace", tenant_id)) + if not account_revision or not workspace_revision: + raise PrivilegedSupportAccessError( + "support_access_revision_missing", + "Support account and workspace revisions are required.", + ) + if set(revisions) != expected_revision_scope: + raise PrivilegedSupportAccessError( + "support_access_revision_invalid", + "Support access revisions must match the approved account and workspace scope.", + ) + canonical = { + "operation_id": normalized_operation_id, + "support_account_id": support_account_id, + "tenant_id": tenant_id, + "permission_scopes": list(scopes), + "subject_reference_hash": privileged_payload_hash(subject_reference), + "account_revision": account_revision, + "workspace_revision": workspace_revision, + "issued_at": normalized_issued_at.isoformat(), + "expires_at": expires_at.isoformat(), + } + policy_hash = privileged_payload_hash({"kind": "support_access_policy", **canonical}) + return PrivilegedSupportAccessGrant( + grant_id=f"support-grant-{policy_hash[:24]}", + operation_id=normalized_operation_id, + support_account_id=support_account_id, + tenant_id=tenant_id, + permission_scopes=scopes, + subject_reference_hash=canonical["subject_reference_hash"], + account_revision=account_revision, + workspace_revision=workspace_revision, + issued_at=normalized_issued_at, + expires_at=expires_at, + policy_hash=policy_hash, + ) + + +def check_privileged_support_access( + grant: PrivilegedSupportAccessGrant, + *, + account_id: str, + tenant_id: str, + requested_scope: str, + account_status: str, + workspace_status: str, + current_account_revision: str, + current_workspace_revision: str, + checked_at: datetime, +) -> PrivilegedSupportAccessCheck: + """Evaluate current state while keeping actual access hard-disabled.""" + + now = _as_utc(checked_at) + if not validate_privileged_support_access_grant(grant): + return _check(grant, now, "blocked", False, "support_access_policy_invalid", 0) + seconds_until_expiry = max(0, int((_as_utc(grant.expires_at) - now).total_seconds())) + if grant.revoked_at is not None: + return _check(grant, now, "revoked", False, "support_access_revoked", seconds_until_expiry) + if now < _as_utc(grant.issued_at): + return _check(grant, now, "blocked", False, "support_access_not_started", seconds_until_expiry) + if now >= _as_utc(grant.expires_at): + return _check(grant, now, "expired", False, "support_access_expired", 0) + if account_id != grant.support_account_id or tenant_id != grant.tenant_id: + return _check(grant, now, "blocked", False, "support_access_subject_mismatch", seconds_until_expiry) + if requested_scope not in grant.permission_scopes: + return _check(grant, now, "blocked", False, "support_access_scope_denied", seconds_until_expiry) + if account_status != "active" or workspace_status != "active": + return _check(grant, now, "blocked", False, "support_access_principal_inactive", seconds_until_expiry) + if ( + current_account_revision != grant.account_revision + or current_workspace_revision != grant.workspace_revision + ): + return _check(grant, now, "blocked", False, "support_access_revision_mismatch", seconds_until_expiry) + return _check(grant, now, "validation_only", True, "support_access_executor_disabled", seconds_until_expiry) + + +def validate_privileged_support_access_grant(grant: PrivilegedSupportAccessGrant) -> bool: + """Verify approved policy material and immutable revocation material.""" + + try: + issued_at = _as_utc(grant.issued_at) + expires_at = _as_utc(grant.expires_at) + if ( + not grant.operation_id.strip() + or len(grant.operation_id) > 64 + or grant.operation_id != grant.operation_id.strip() + or not grant.support_account_id.strip() + or grant.support_account_id != grant.support_account_id.strip() + or len(grant.support_account_id) > 64 + or not grant.tenant_id.strip() + or grant.tenant_id != grant.tenant_id.strip() + or len(grant.tenant_id) > 64 + or not grant.permission_scopes + or tuple(sorted(set(grant.permission_scopes))) != grant.permission_scopes + or not set(grant.permission_scopes) <= SUPPORT_ACCESS_ALLOWED_SCOPES + or not grant.account_revision.strip() + or not grant.workspace_revision.strip() + or grant.access_enabled is not False + or expires_at <= issued_at + or expires_at - issued_at > _SUPPORT_ACCESS_MAX_TTL + ): + return False + canonical = { + "operation_id": grant.operation_id, + "support_account_id": grant.support_account_id, + "tenant_id": grant.tenant_id, + "permission_scopes": list(grant.permission_scopes), + "subject_reference_hash": grant.subject_reference_hash, + "account_revision": grant.account_revision, + "workspace_revision": grant.workspace_revision, + "issued_at": issued_at.isoformat(), + "expires_at": expires_at.isoformat(), + } + expected_policy_hash = privileged_payload_hash({"kind": "support_access_policy", **canonical}) + if grant.policy_hash != expected_policy_hash or grant.grant_id != f"support-grant-{expected_policy_hash[:24]}": + return False + if grant.revoked_at is None: + return grant.revocation_code is None and grant.revocation_hash is None + if grant.revocation_code not in _SAFE_REVOCATION_CODES or grant.revocation_hash is None: + return False + revoked_at = _as_utc(grant.revoked_at) + if revoked_at < issued_at: + return False + expected_revocation_hash = privileged_payload_hash( + { + "kind": "support_access_revocation", + "policy_hash": grant.policy_hash, + "revoked_at": revoked_at.isoformat(), + "revocation_code": grant.revocation_code, + } + ) + return grant.revocation_hash == expected_revocation_hash + except (AttributeError, TypeError, ValueError, PrivilegedSupportAccessError): + return False + + +def revoke_privileged_support_access( + grant: PrivilegedSupportAccessGrant, + *, + revoked_at: datetime, + revocation_code: str, +) -> PrivilegedSupportAccessGrant: + """Return an immutable emergency-revoked policy snapshot.""" + + if not validate_privileged_support_access_grant(grant): + raise PrivilegedSupportAccessError( + "support_access_policy_invalid", + "Support access policy material is invalid.", + ) + if revocation_code not in _SAFE_REVOCATION_CODES: + raise PrivilegedSupportAccessError( + "support_access_revocation_invalid", + "A stable emergency revocation code is required.", + ) + normalized_revoked_at = _as_utc(revoked_at) + if normalized_revoked_at < _as_utc(grant.issued_at): + raise PrivilegedSupportAccessError( + "support_access_revocation_invalid", + "Support access cannot be revoked before issuance.", + ) + if grant.revoked_at is not None: + if grant.revoked_at == normalized_revoked_at and grant.revocation_code == revocation_code: + return grant + raise PrivilegedSupportAccessError( + "support_access_already_revoked", + "A revoked support access policy cannot be rewritten.", + ) + revocation_hash = privileged_payload_hash( + { + "kind": "support_access_revocation", + "policy_hash": grant.policy_hash, + "revoked_at": normalized_revoked_at.isoformat(), + "revocation_code": revocation_code, + } + ) + return grant.model_copy( + update={ + "revoked_at": normalized_revoked_at, + "revocation_code": revocation_code, + "revocation_hash": revocation_hash, + } + ) + + +def _check( + grant: PrivilegedSupportAccessGrant, + checked_at: datetime, + effective_status: Literal["validation_only", "expired", "revoked", "blocked"], + policy_satisfied: bool, + code: str, + seconds_until_expiry: int, +) -> PrivilegedSupportAccessCheck: + return PrivilegedSupportAccessCheck( + grant_id=grant.grant_id, + checked_at=checked_at, + effective_status=effective_status, + policy_satisfied=policy_satisfied, + code=code, + seconds_until_expiry=seconds_until_expiry, + ) + + +def _revision_map( + revisions: list[PrivilegedIdentityExpectedRevision | dict[str, object]], +) -> dict[tuple[str, str], str]: + result: dict[tuple[str, str], str] = {} + for raw in revisions: + try: + revision = ( + raw + if isinstance(raw, PrivilegedIdentityExpectedRevision) + else PrivilegedIdentityExpectedRevision.model_validate(raw) + ) + except (TypeError, ValueError) as exc: + raise PrivilegedSupportAccessError( + "support_access_revision_invalid", + "Support access revision material is invalid.", + ) from exc + key = (revision.entity_type.strip(), revision.entity_id.strip()) + if not all((*key, revision.revision.strip())) or key in result: + raise PrivilegedSupportAccessError( + "support_access_revision_invalid", + "Support access revisions must be unique and non-empty.", + ) + result[key] = revision.revision.strip() + return result + + +def _required_text(value: dict[str, object], key: str) -> str: + item = value.get(key) + if not isinstance(item, str) or not item.strip(): + raise PrivilegedSupportAccessError( + "support_access_target_invalid", + f"Support access target '{key}' is required.", + ) + return item.strip() + + +def _required_scope(value: object) -> str: + if not isinstance(value, str) or not value.strip(): + raise PrivilegedSupportAccessError( + "support_access_scope_invalid", + "Support access scopes must be non-empty strings.", + ) + return value.strip() + + +def _parse_datetime(value: object, field: str) -> datetime: + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) if isinstance(value, str) else value + except ValueError as exc: + raise PrivilegedSupportAccessError( + "support_access_expiry_invalid", + f"Support access {field} is invalid.", + ) from exc + if not isinstance(parsed, datetime) or parsed.tzinfo is None: + raise PrivilegedSupportAccessError( + "support_access_expiry_invalid", + f"Support access {field} must include a timezone.", + ) + return _as_utc(parsed) + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + raise PrivilegedSupportAccessError( + "support_access_time_invalid", + "Support access timestamps must include a timezone.", + ) + return value.astimezone(UTC) + + +__all__ = [ + "PrivilegedSupportAccessCheck", + "PrivilegedSupportAccessError", + "PrivilegedSupportAccessGrant", + "SUPPORT_ACCESS_ALLOWED_SCOPES", + "build_privileged_support_access_grant", + "check_privileged_support_access", + "revoke_privileged_support_access", + "validate_privileged_support_access_grant", +] diff --git a/yuqei-contract-platform/services/contract-api/tests/test_anti_abuse.py b/yuqei-contract-platform/services/contract-api/tests/test_anti_abuse.py index 71ce0bd7..4540c94f 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_anti_abuse.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_anti_abuse.py @@ -41,6 +41,26 @@ def test_privileged_outbox_read_routes_use_sensitive_s3_policy() -> None: ) is None +def test_privileged_approval_sla_routes_use_sensitive_s3_policy() -> None: + policies = contract_endpoint_risk_policies("/api/v1") + + for path in ( + "/api/v1/identity/privileged-approval-sla/overview", + "/api/v1/identity/privileged-approval-sla/queue", + ): + policy = match_policy("GET", path, policies) + assert policy is not None + assert policy.route_key == "identity.privileged_approval_sla.read" + assert policy.data_class == "S3" + assert policy.sensitive is True + + assert match_policy( + "POST", + "/api/v1/identity/privileged-approval-sla/queue", + policies, + ) is None + + def test_contract_anti_abuse_observe_marks_sensitive_responses() -> None: app = create_app(make_settings(anti_abuse_mode="observe")) client = TestClient(app) diff --git a/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_delivery_contract.py b/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_delivery_contract.py new file mode 100644 index 00000000..99e915ee --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_delivery_contract.py @@ -0,0 +1,209 @@ +from dataclasses import replace +import json +from types import SimpleNamespace + +import pytest + +from yuqei_contract_api.privileged_identity_delivery import ( + InMemoryPrivilegedIdentityConsumer, + PrivilegedIdentityDeliveryContract, + PrivilegedIdentityDeliveryError, + RetryablePrivilegedIdentityDeliveryError, +) + + +SIGNING_KEY_MATERIAL = bytes([17]) * 32 +DELIVERY_KEY_MATERIAL = bytes([29]) * 32 + + +def _message( + *, + idempotency_key: str = "privileged-identity:internal-key-one", + event_type: str = "operation_submitted", +) -> SimpleNamespace: + return SimpleNamespace( + effect_type="privileged_identity.operation_event", + idempotency_key=idempotency_key, + payload={ + "event_id": "privileged-event-internal-001", + "operation_id": "client-controlled-operation-001", + "sequence_no": 1, + "event_type": event_type, + "operation_type": "account_merge", + "status_before": None, + "status_after": "pending_approval", + "approval_count": 0, + "target_reference_count": 2, + "target_reference_fingerprints": ["dictionary-attackable-fingerprint"], + "evidence_reference_count": 1, + "warning_count": 0, + "trace_id": "client-trace-id", + "snapshot_before_hash": "internal-before-hash", + "snapshot_after_hash": "internal-after-hash", + "occurred_at": "2026-08-03T00:00:00+00:00", + }, + ) + + +def _contract() -> PrivilegedIdentityDeliveryContract: + return PrivilegedIdentityDeliveryContract( + key_id="identity-egress-2026-08", + signing_secret=SIGNING_KEY_MATERIAL, + delivery_key_secret=DELIVERY_KEY_MATERIAL, + ) + + +def test_contract_requires_a_strong_named_key() -> None: + with pytest.raises(PrivilegedIdentityDeliveryError) as short_secret: + PrivilegedIdentityDeliveryContract( + key_id="key-1", + signing_secret=bytes([1]) * 8, + delivery_key_secret=DELIVERY_KEY_MATERIAL, + ) + assert short_secret.value.code == "signing_key_too_short" + + with pytest.raises(PrivilegedIdentityDeliveryError) as short_delivery_key: + PrivilegedIdentityDeliveryContract( + key_id="key-1", + signing_secret=SIGNING_KEY_MATERIAL, + delivery_key_secret=bytes([1]) * 8, + ) + assert short_delivery_key.value.code == "delivery_key_too_short" + + with pytest.raises(PrivilegedIdentityDeliveryError) as invalid_key_id: + PrivilegedIdentityDeliveryContract( + key_id="unsafe key id", + signing_secret=SIGNING_KEY_MATERIAL, + delivery_key_secret=DELIVERY_KEY_MATERIAL, + ) + assert invalid_key_id.value.code == "invalid_key_id" + + +def test_envelope_is_minimal_signed_and_stable_across_retries() -> None: + contract = _contract() + first = contract.build(_message()) + second = contract.build(_message()) + + assert first == second + assert contract.verify(first) is True + assert len(first.delivery_key) == 64 + assert len(first.body_digest) == 64 + assert len(first.signature) == 64 + document = json.loads(first.canonical_document()) + assert set(document) == { + "body_digest", + "contract_version", + "delivery_key", + "effect_type", + "event_type", + "key_id", + "occurred_at", + "operation_type", + "signature", + "status_after", + "status_before", + } + serialized = first.canonical_document().decode("utf-8").lower() + for forbidden in ( + "client-controlled-operation-001", + "privileged-event-internal-001", + "client-trace-id", + "dictionary-attackable-fingerprint", + "internal-before-hash", + "internal-after-hash", + "internal-key-one", + "snapshot", + "target_reference", + "actor", + "tenant", + "user_id", + "ticket", + "reason", + ): + assert forbidden not in serialized + + +def test_contract_rejects_invalid_lifecycle_values_and_naive_timestamps() -> None: + contract = _contract() + invalid_status = _message() + invalid_status.payload["status_after"] = "unknown" + with pytest.raises(PrivilegedIdentityDeliveryError) as status_error: + contract.build(invalid_status) + assert status_error.value.code == "invalid_status" + + naive_timestamp = _message() + naive_timestamp.payload["occurred_at"] = "2026-08-03T00:00:00" + with pytest.raises(PrivilegedIdentityDeliveryError) as timestamp_error: + contract.build(naive_timestamp) + assert timestamp_error.value.code == "invalid_occurred_at" + + +def test_delivery_keys_are_domain_separated_and_rotate_with_the_key() -> None: + first = _contract().build(_message()) + different_message = _contract().build( + _message(idempotency_key="privileged-identity:internal-key-two") + ) + rotated = PrivilegedIdentityDeliveryContract( + key_id="identity-egress-2026-09", + signing_secret=bytes([43]) * 32, + delivery_key_secret=DELIVERY_KEY_MATERIAL, + ).build(_message()) + + assert first.delivery_key != different_message.delivery_key + assert first.delivery_key == rotated.delivery_key + assert first.signature != rotated.signature + + +def test_reference_consumer_deduplicates_ack_loss_and_rejects_conflicts() -> None: + applied: list[str] = [] + contract = _contract() + consumer = InMemoryPrivilegedIdentityConsumer( + "reference-consumer", + {contract.key_id: contract}, + apply=lambda envelope: applied.append(envelope.delivery_key), + ) + first = contract.build(_message()) + + accepted = consumer.deliver(first) + duplicate_after_lost_ack = consumer.deliver(first) + conflicting = consumer.deliver(contract.build(_message(event_type="approval_recorded"))) + + assert accepted.disposition == "accepted" + assert duplicate_after_lost_ack.disposition == "duplicate" + assert conflicting.disposition == "permanent_failure" + assert conflicting.error_code == "idempotency_conflict" + assert applied == [first.delivery_key] + assert consumer.receipt_count == 1 + + +def test_reference_consumer_classifies_retryable_and_authentication_failures() -> None: + contract = _contract() + attempts = 0 + + def retry_once(_envelope) -> None: + nonlocal attempts + attempts += 1 + if attempts == 1: + raise RetryablePrivilegedIdentityDeliveryError("dependency unavailable") + + consumer = InMemoryPrivilegedIdentityConsumer( + "retrying-consumer", + {contract.key_id: contract}, + apply=retry_once, + ) + envelope = contract.build(_message()) + retryable = consumer.deliver(envelope) + accepted = consumer.deliver(envelope) + invalid_signature = consumer.deliver(replace(envelope, signature="0" * 64)) + unknown_schema = consumer.deliver( + replace(envelope, contract_version="privileged-identity-egress.v2") + ) + + assert retryable.disposition == "retryable_failure" + assert retryable.error_code == "consumer_unavailable" + assert accepted.disposition == "accepted" + assert invalid_signature.disposition == "permanent_failure" + assert invalid_signature.error_code == "invalid_signature" + assert unknown_schema.disposition == "permanent_failure" + assert unknown_schema.error_code == "unsupported_schema_major" + assert attempts == 2 diff --git a/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_migration.py b/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_migration.py index ea0d17f7..37f91001 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_migration.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_migration.py @@ -74,6 +74,64 @@ def _insert_migration_operation_and_event( ) +def _insert_execution_attempt_and_event( + connection: Connection, + *, + operation_id: str, + suffix: str, + now: str, +) -> None: + connection.execute( + text( + """ + INSERT INTO privileged_identity_execution_attempts ( + attempt_id, operation_id, attempt_no, idempotency_key, + request_hash, preview_hash, preflight_hash, trace_id, created_at + ) VALUES ( + :attempt_id, :operation_id, 1, :idempotency_key, + :request_hash, :preview_hash, :preflight_hash, :trace_id, :now + ) + """ + ), + { + "attempt_id": f"migration-attempt-{suffix}", + "operation_id": operation_id, + "idempotency_key": f"migration-attempt-key-{suffix}", + "request_hash": "1" * 64, + "preview_hash": "2" * 64, + "preflight_hash": "3" * 64, + "trace_id": f"trace-migration-attempt-{suffix}", + "now": now, + }, + ) + connection.execute( + text( + """ + INSERT INTO privileged_identity_execution_attempt_events ( + event_id, attempt_id, sequence_no, event_type, + status_before, status_after, error_code, trace_id, + snapshot_before, snapshot_after, snapshot_before_hash, + snapshot_after_hash, event_payload_hash, created_at + ) VALUES ( + :event_id, :attempt_id, 1, 'attempt_prepared', + NULL, 'prepared', NULL, :trace_id, + '{}', '{"status":"prepared"}', :before_hash, + :after_hash, :payload_hash, :now + ) + """ + ), + { + "event_id": f"migration-attempt-event-{suffix}", + "attempt_id": f"migration-attempt-{suffix}", + "trace_id": f"trace-migration-attempt-{suffix}", + "before_hash": "4" * 64, + "after_hash": "5" * 64, + "payload_hash": "6" * 64, + "now": now, + }, + ) + + def test_privileged_identity_operation_migration_upgrade_and_downgrade( tmp_path: Path, monkeypatch, @@ -90,12 +148,31 @@ def test_privileged_identity_operation_migration_upgrade_and_downgrade( inspector = inspect(engine) assert "privileged_identity_operations" not in inspector.get_table_names() + command.upgrade(config, "20260803_0041") + inspector = inspect(engine) + assert "preview_expires_at" not in { + column["name"] + for column in inspector.get_columns("privileged_identity_operations") + } + with engine.begin() as connection: + _insert_migration_operation_and_event( + connection, + suffix="legacy", + now=datetime.now(UTC).isoformat(), + ) + command.upgrade(config, "head") inspector = inspect(engine) + with engine.connect() as connection: + assert connection.execute( + text("SELECT version_num FROM contract_api_alembic_version") + ).scalar_one() == "20260803_0043" assert "privileged_identity_operations" in inspector.get_table_names() assert "privileged_identity_operation_approvals" in inspector.get_table_names() assert "privileged_identity_operation_events" in inspector.get_table_names() assert "privileged_identity_outbox_messages" in inspector.get_table_names() + assert "privileged_identity_execution_attempts" in inspector.get_table_names() + assert "privileged_identity_execution_attempt_events" in inspector.get_table_names() operation_columns = { column["name"] for column in inspector.get_columns("privileged_identity_operations") @@ -108,8 +185,16 @@ def test_privileged_identity_operation_migration_upgrade_and_downgrade( "requested_by_account_id", "preview_hash", "expected_revisions", + "preview_expires_at", "expires_at", } <= operation_columns + with engine.connect() as connection: + assert connection.execute( + text( + "SELECT preview_expires_at FROM privileged_identity_operations " + "WHERE operation_id = 'migration-operation-legacy'" + ) + ).scalar_one() is None approval_unique_constraints = { item["name"] for item in inspector.get_unique_constraints( @@ -240,6 +325,238 @@ def test_privileged_identity_operation_migration_upgrade_and_downgrade( } assert "ck_privileged_identity_outbox_lease_state" in outbox_check_constraints + attempt_columns = { + column["name"] + for column in inspector.get_columns( + "privileged_identity_execution_attempts" + ) + } + assert attempt_columns == { + "attempt_id", + "operation_id", + "attempt_no", + "idempotency_key", + "request_hash", + "preview_hash", + "preflight_hash", + "trace_id", + "created_at", + } + attempt_unique_constraints = { + item["name"] + for item in inspector.get_unique_constraints( + "privileged_identity_execution_attempts" + ) + } + assert { + "ux_privileged_identity_execution_attempts_operation_number", + "ux_privileged_identity_execution_attempts_idempotency_key", + } <= attempt_unique_constraints + attempt_indexes = { + item["name"] + for item in inspector.get_indexes( + "privileged_identity_execution_attempts" + ) + } + assert { + "ix_privileged_identity_execution_attempts_operation_created", + "ix_privileged_identity_execution_attempts_trace", + } <= attempt_indexes + attempt_foreign_keys = inspector.get_foreign_keys( + "privileged_identity_execution_attempts" + ) + assert {item["referred_table"] for item in attempt_foreign_keys} == { + "privileged_identity_operations" + } + assert all( + item.get("options", {}).get("ondelete") in {None, "NO ACTION"} + for item in attempt_foreign_keys + ) + + attempt_event_columns = { + column["name"] + for column in inspector.get_columns( + "privileged_identity_execution_attempt_events" + ) + } + assert attempt_event_columns == { + "event_id", + "attempt_id", + "sequence_no", + "event_type", + "status_before", + "status_after", + "error_code", + "trace_id", + "snapshot_before", + "snapshot_after", + "snapshot_before_hash", + "snapshot_after_hash", + "event_payload_hash", + "created_at", + } + attempt_event_unique_constraints = { + item["name"] + for item in inspector.get_unique_constraints( + "privileged_identity_execution_attempt_events" + ) + } + assert { + "ux_priv_id_exec_attempt_events_attempt_sequence", + "ux_priv_id_exec_attempt_events_attempt_payload", + } <= attempt_event_unique_constraints + attempt_event_indexes = { + item["name"] + for item in inspector.get_indexes( + "privileged_identity_execution_attempt_events" + ) + } + assert { + "ix_privileged_identity_execution_attempt_events_attempt_created", + "ix_privileged_identity_execution_attempt_events_trace", + } <= attempt_event_indexes + attempt_event_foreign_keys = inspector.get_foreign_keys( + "privileged_identity_execution_attempt_events" + ) + assert {item["referred_table"] for item in attempt_event_foreign_keys} == { + "privileged_identity_execution_attempts" + } + assert all( + item.get("options", {}).get("ondelete") in {None, "NO ACTION"} + for item in attempt_event_foreign_keys + ) + with engine.connect() as connection: + execution_triggers = { + row[0] + for row in connection.exec_driver_sql( + "SELECT name FROM sqlite_master WHERE type = 'trigger'" + ) + } + assert { + "trg_privileged_identity_execution_attempts_update_blocked", + "trg_privileged_identity_execution_attempts_delete_blocked", + "trg_privileged_identity_execution_attempt_events_update_blocked", + "trg_privileged_identity_execution_attempt_events_delete_blocked", + } <= execution_triggers + + execution_now = datetime.now(UTC).isoformat() + with engine.begin() as connection: + _insert_execution_attempt_and_event( + connection, + operation_id="migration-operation-legacy", + suffix="ledger", + now=execution_now, + ) + duplicate_attempt_selects = ( + ( + "migration-attempt-duplicate-number", + "attempt_no", + "1", + "idempotency_key", + "'migration-attempt-key-duplicate-number'", + ), + ( + "migration-attempt-duplicate-key", + "attempt_no", + "2", + "idempotency_key", + "idempotency_key", + ), + ) + for duplicate_id, _, attempt_no, _, idempotency_key in duplicate_attempt_selects: + with pytest.raises(DBAPIError): + with engine.begin() as connection: + connection.execute( + text( + """ + INSERT INTO privileged_identity_execution_attempts ( + attempt_id, operation_id, attempt_no, idempotency_key, + request_hash, preview_hash, preflight_hash, trace_id, + created_at + ) + SELECT :duplicate_id, operation_id, + """ + + attempt_no + + ", " + + idempotency_key + + """, + request_hash, preview_hash, preflight_hash, trace_id, + created_at + FROM privileged_identity_execution_attempts + WHERE attempt_id = 'migration-attempt-ledger' + """ + ), + {"duplicate_id": duplicate_id}, + ) + duplicate_event_selects = ( + ("migration-attempt-event-duplicate-sequence", "1", "'7' || substr(event_payload_hash, 2)"), + ("migration-attempt-event-duplicate-payload", "2", "event_payload_hash"), + ) + for duplicate_id, sequence_no, payload_hash in duplicate_event_selects: + with pytest.raises(DBAPIError): + with engine.begin() as connection: + connection.execute( + text( + """ + INSERT INTO privileged_identity_execution_attempt_events ( + event_id, attempt_id, sequence_no, event_type, + status_before, status_after, error_code, trace_id, + snapshot_before, snapshot_after, snapshot_before_hash, + snapshot_after_hash, event_payload_hash, created_at + ) + SELECT :duplicate_id, attempt_id, + """ + + sequence_no + + """, event_type, + status_before, status_after, error_code, trace_id, + snapshot_before, snapshot_after, snapshot_before_hash, + snapshot_after_hash, """ + + payload_hash + + """, created_at + FROM privileged_identity_execution_attempt_events + WHERE event_id = 'migration-attempt-event-ledger' + """ + ), + {"duplicate_id": duplicate_id}, + ) + for table_name, id_column, row_id in ( + ( + "privileged_identity_execution_attempts", + "attempt_id", + "migration-attempt-ledger", + ), + ( + "privileged_identity_execution_attempt_events", + "event_id", + "migration-attempt-event-ledger", + ), + ): + with pytest.raises(DBAPIError): + with engine.begin() as connection: + connection.execute( + text( + f"UPDATE {table_name} SET trace_id = 'tampered' " + f"WHERE {id_column} = :row_id" + ), + {"row_id": row_id}, + ) + with pytest.raises(DBAPIError): + with engine.begin() as connection: + connection.execute( + text(f"DELETE FROM {table_name} WHERE {id_column} = :row_id"), + {"row_id": row_id}, + ) + + command.downgrade(config, "20260803_0042") + downgraded_to_0042 = inspect(engine).get_table_names() + assert "privileged_identity_execution_attempts" not in downgraded_to_0042 + assert "privileged_identity_execution_attempt_events" not in downgraded_to_0042 + command.upgrade(config, "head") + assert { + "privileged_identity_execution_attempts", + "privileged_identity_execution_attempt_events", + } <= set(inspect(engine).get_table_names()) + now = datetime.now(UTC).isoformat() with engine.begin() as connection: _insert_migration_operation_and_event( @@ -247,6 +564,12 @@ def test_privileged_identity_operation_migration_upgrade_and_downgrade( suffix="pending", now=now, ) + assert connection.execute( + text( + "SELECT preview_expires_at FROM privileged_identity_operations " + "WHERE operation_id = 'migration-operation-pending'" + ) + ).scalar_one() is None connection.execute( text( """ @@ -297,13 +620,28 @@ def test_privileged_identity_operation_migration_upgrade_and_downgrade( assert "privileged_identity_operation_approvals" not in downgraded_tables assert "privileged_identity_operation_events" not in downgraded_tables assert "privileged_identity_outbox_messages" not in downgraded_tables + assert "privileged_identity_execution_attempts" not in downgraded_tables + assert "privileged_identity_execution_attempt_events" not in downgraded_tables command.upgrade(config, "head") - reupgraded_tables = inspect(engine).get_table_names() + reupgraded_inspector = inspect(engine) + reupgraded_tables = reupgraded_inspector.get_table_names() assert "privileged_identity_operations" in reupgraded_tables assert "privileged_identity_operation_approvals" in reupgraded_tables assert "privileged_identity_operation_events" in reupgraded_tables assert "privileged_identity_outbox_messages" in reupgraded_tables + assert "privileged_identity_execution_attempts" in reupgraded_tables + assert "privileged_identity_execution_attempt_events" in reupgraded_tables + assert "preview_expires_at" in { + column["name"] + for column in reupgraded_inspector.get_columns( + "privileged_identity_operations" + ) + } + with engine.connect() as connection: + assert connection.execute( + text("SELECT version_num FROM contract_api_alembic_version") + ).scalar_one() == "20260803_0043" engine.dispose() finally: get_settings.cache_clear() diff --git a/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_operations.py b/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_operations.py index 4c4c72ae..0f2eab1c 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_operations.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_operations.py @@ -15,17 +15,28 @@ from yuqei_contract_api.config import ContractApiSettings from yuqei_contract_api.main import create_app from yuqei_contract_api.persistence.models import ( GlobalAccountModel, + GlobalAccountCredentialModel, + PrivilegedIdentityExecutionAttemptEventModel, + PrivilegedIdentityExecutionAttemptModel, PrivilegedIdentityOperationApprovalModel, PrivilegedIdentityOperationEventModel, PrivilegedIdentityOperationModel, PrivilegedIdentityOutboxMessageModel, + TenantModel, TenantUserModel, + TenantUserCredentialModel, WorkspaceMembershipModel, ) from yuqei_contract_api.privileged_identity import ( PrivilegedIdentityOperationError, privileged_payload_hash, ) +from yuqei_contract_api.privileged_identity_delivery import ( + InMemoryPrivilegedIdentityConsumer, + PrivilegedIdentityDeliveryContract, + PrivilegedIdentityDeliveryReceipt, + RetryablePrivilegedIdentityDeliveryError, +) from yuqei_contract_api.persistence.privileged_identity_outbox import ( PrivilegedIdentityOutboxRepository, PrivilegedIdentityOutboxWorker, @@ -161,69 +172,48 @@ def submit_from_preview( return response.json() -def governance_storage_snapshot(repository) -> tuple[tuple, tuple, tuple]: +def governance_storage_snapshot(repository) -> tuple[tuple, ...]: with repository._session_factory() as session: - operations = session.execute( - select(PrivilegedIdentityOperationModel).order_by( - PrivilegedIdentityOperationModel.operation_id - ) - ).scalars().all() - events = session.execute( - select(PrivilegedIdentityOperationEventModel).order_by( - PrivilegedIdentityOperationEventModel.event_id - ) - ).scalars().all() - messages = session.execute( - select(PrivilegedIdentityOutboxMessageModel).order_by( - PrivilegedIdentityOutboxMessageModel.message_id - ) - ).scalars().all() - return ( - tuple( - ( - item.operation_id, - item.status, - item.request_payload_hash, - item.preview_hash, - item.updated_at, + snapshots: list[tuple] = [] + for model in ( + PrivilegedIdentityOperationModel, + PrivilegedIdentityOperationApprovalModel, + PrivilegedIdentityOperationEventModel, + PrivilegedIdentityOutboxMessageModel, + GlobalAccountModel, + GlobalAccountCredentialModel, + TenantModel, + TenantUserModel, + TenantUserCredentialModel, + WorkspaceMembershipModel, + ): + primary_key = tuple(model.__table__.primary_key.columns) + rows = session.execute(select(model).order_by(*primary_key)).scalars().all() + snapshots.append( + tuple( + tuple(getattr(item, column.key) for column in model.__table__.columns) + for item in rows ) - for item in operations - ), - tuple( - ( - item.event_id, - item.operation_id, - item.event_type, - item.status_before, - item.status_after, - item.snapshot_before_hash, - item.snapshot_after_hash, - item.created_at, + ) + return tuple(snapshots) + + +def execution_ledger_snapshot(repository) -> tuple[tuple, ...]: + with repository._session_factory() as session: + snapshots: list[tuple] = [] + for model in ( + PrivilegedIdentityExecutionAttemptModel, + PrivilegedIdentityExecutionAttemptEventModel, + ): + primary_key = tuple(model.__table__.primary_key.columns) + rows = session.execute(select(model).order_by(*primary_key)).scalars().all() + snapshots.append( + tuple( + tuple(getattr(item, column.key) for column in model.__table__.columns) + for item in rows ) - for item in events - ), - tuple( - ( - item.message_id, - item.operation_id, - item.event_id, - item.status, - item.attempt_count, - item.max_attempts, - item.idempotency_key, - json.dumps(item.payload, sort_keys=True), - item.payload_hash, - item.available_at, - item.lease_owner, - item.lease_expires_at, - item.last_error_code, - item.last_attempt_at, - item.delivered_at, - item.updated_at, - ) - for item in messages - ), - ) + ) + return tuple(snapshots) def test_preview_is_platform_only_read_only_and_minimally_disclosed(tmp_path: Path) -> None: @@ -694,6 +684,580 @@ def test_two_distinct_current_admins_are_required_and_requester_cannot_approve(t assert target.platform_permissions == [] +def test_execution_preflight_is_read_only_and_uses_a_fixed_lock_plan(tmp_path: Path) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + repository = app.state.contract_repository.identity + add_identity( + repository, + account_id="account-preflight-target", + user_id="user-preflight-target", + email="preflight-target@example.com", + platform_admin=False, + ) + for suffix in ("one", "two"): + add_identity( + repository, + account_id=f"account-preflight-approver-{suffix}", + user_id=f"user-preflight-approver-{suffix}", + email=f"preflight-approver-{suffix}@example.com", + platform_admin=True, + ) + preview = grant_preview(client, user_id="user-preflight-target") + operation = submit_from_preview( + client, + preview, + operation_id="operation-preflight-001", + ) + preflight_path = ( + "/api/v1/identity/privileged-operations/operation-preflight-001/" + "execution-preflight" + ) + + pending_before = governance_storage_snapshot(repository) + pending = client.get(preflight_path, headers=identity_headers()) + pending_after = governance_storage_snapshot(repository) + assert pending.status_code == 200 + assert pending_before == pending_after + assert pending.json()["ready"] is False + assert {item["code"] for item in pending.json()["blockers"]} == { + "operation_not_approved", + "approval_quorum_missing", + } + + decision = { + "decision": "approve", + "comment": "Execution preflight approval evidence verified", + "preview_hash": operation["preview_hash"], + } + for suffix in ("one", "two"): + response = client.post( + "/api/v1/identity/privileged-operations/operation-preflight-001/approvals", + headers=identity_headers(f"user-preflight-approver-{suffix}"), + json=decision, + ) + assert response.status_code == 200, response.text + + before = governance_storage_snapshot(repository) + ready = client.get(preflight_path, headers=identity_headers()) + after = governance_storage_snapshot(repository) + assert ready.status_code == 200 + assert before == after + payload = ready.json() + assert payload["ready"] is True + assert payload["blockers"] == [] + assert payload["stored_status"] == "approved" + assert payload["effective_status"] == "approved" + assert payload["preview_hash_verified"] is True + assert payload["valid_approval_count"] == 2 + assert payload["required_approval_count"] == 2 + assert payload["locks_acquired"] is False + assert payload["executor_enabled"] is False + assert [item["sequence"] for item in payload["lock_plan"]] == list( + range(1, len(payload["lock_plan"]) + 1) + ) + assert [item["resource_type"] for item in payload["lock_plan"]] == [ + "privileged_operation", + "privileged_approval_set", + "identity_account", + "tenant_user", + "workspace_membership", + ] + assert all(item["lock_mode"] == "for_update" for item in payload["lock_plan"]) + assert all(item["matches"] for item in payload["revisions"]) + serialized = json.dumps(payload).lower() + for forbidden in ( + "preflight-target@example.com", + "execution preflight approval evidence verified", + "sup-2026-0802-001", + "target_refs", + "evidence_refs", + ): + assert forbidden not in serialized + + with repository._session_factory.begin() as session: + workspace = session.get(TenantModel, "default") + assert workspace is not None + workspace.status = "suspended" + suspended_caller = client.get(preflight_path, headers=identity_headers()) + assert suspended_caller.status_code == 403 + assert suspended_caller.json()["detail"]["code"] == "platform_admin_required" + with repository._session_factory.begin() as session: + workspace = session.get(TenantModel, "default") + assert workspace is not None + workspace.status = "active" + + forbidden = client.get( + preflight_path, + headers=identity_headers("user-preflight-target"), + ) + assert forbidden.status_code == 403 + missing = client.get( + "/api/v1/identity/privileged-operations/missing-operation/execution-preflight", + headers=identity_headers(), + ) + assert missing.status_code == 404 + + with repository._session_factory.begin() as session: + target = session.get(TenantUserModel, "user-preflight-target") + assert target is not None + target.updated_at = datetime.now(UTC) + timedelta(seconds=10) + stale_before = governance_storage_snapshot(repository) + stale = client.get(preflight_path, headers=identity_headers()) + stale_after = governance_storage_snapshot(repository) + assert stale.status_code == 200 + assert stale_before == stale_after + assert stale.json()["ready"] is False + assert {item["code"] for item in stale.json()["blockers"]} >= { + "revision_mismatch", + "preview_hash_mismatch", + } + + +def test_internal_change_plan_is_deterministic_and_read_only(tmp_path: Path) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + repository = app.state.contract_repository.identity + add_identity( + repository, + account_id="account-plan-target", + user_id="user-plan-target", + email="plan-target@example.com", + platform_admin=False, + ) + for suffix in ("one", "two"): + add_identity( + repository, + account_id=f"account-plan-approver-{suffix}", + user_id=f"user-plan-approver-{suffix}", + email=f"plan-approver-{suffix}@example.com", + platform_admin=True, + ) + preview = grant_preview(client, user_id="user-plan-target") + operation = submit_from_preview( + client, + preview, + operation_id="operation-internal-plan-001", + ) + for suffix in ("one", "two"): + approval = client.post( + "/api/v1/identity/privileged-operations/operation-internal-plan-001/approvals", + headers=identity_headers(f"user-plan-approver-{suffix}"), + json={ + "decision": "approve", + "comment": "Internal deterministic plan approval", + "preview_hash": operation["preview_hash"], + }, + ) + assert approval.status_code == 200, approval.text + + before = governance_storage_snapshot(repository) + first = repository.privileged_operations.build_internal_change_plan( + "operation-internal-plan-001", + actor_tenant_id="default", + actor_user_id="user-current-admin", + ) + second = repository.privileged_operations.build_internal_change_plan( + "operation-internal-plan-001", + actor_tenant_id="default", + actor_user_id="user-current-admin", + ) + after = governance_storage_snapshot(repository) + + assert before == after + assert first == second + assert first.operation_type == "platform_admin_grant" + assert first.executor_enabled is False + assert len(first.steps) == 1 + assert len(first.compensation_steps) == 1 + assert first.plan_hash != first.compensation_plan_hash + serialized = first.model_dump_json().lower() + assert "plan-target@example.com" not in serialized + assert "internal deterministic plan approval" not in serialized + + +def test_execution_preflight_blocks_stale_approvals_legacy_rows_and_expiry(tmp_path: Path) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + repository = app.state.contract_repository.identity + add_identity( + repository, + account_id="account-preflight-second-target", + user_id="user-preflight-second-target", + email="preflight-second-target@example.com", + platform_admin=False, + ) + for suffix in ("one", "two"): + add_identity( + repository, + account_id=f"account-preflight-second-approver-{suffix}", + user_id=f"user-preflight-second-approver-{suffix}", + email=f"preflight-second-approver-{suffix}@example.com", + platform_admin=True, + ) + preview = grant_preview(client, user_id="user-preflight-second-target") + operation = submit_from_preview( + client, + preview, + operation_id="operation-preflight-002", + ) + decision = { + "decision": "approve", + "comment": "Second execution preflight approval", + "preview_hash": operation["preview_hash"], + } + for suffix in ("one", "two"): + response = client.post( + "/api/v1/identity/privileged-operations/operation-preflight-002/approvals", + headers=identity_headers(f"user-preflight-second-approver-{suffix}"), + json=decision, + ) + assert response.status_code == 200, response.text + path = ( + "/api/v1/identity/privileged-operations/operation-preflight-002/" + "execution-preflight" + ) + + now = datetime.now(UTC) + with repository._session_factory.begin() as session: + session.add( + TenantModel( + id="preflight-other-workspace", + name="Preflight other workspace", + workspace_type="organization", + owner_user_id=None, + legal_name=None, + status="active", + plan=None, + region=None, + settings={}, + created_at=now, + updated_at=now, + ) + ) + approval = session.execute( + select(PrivilegedIdentityOperationApprovalModel).where( + PrivilegedIdentityOperationApprovalModel.operation_id + == "operation-preflight-002", + PrivilegedIdentityOperationApprovalModel.approver_user_id + == "user-preflight-second-approver-one", + ) + ).scalar_one() + membership = session.execute( + select(WorkspaceMembershipModel).where( + WorkspaceMembershipModel.tenant_user_id + == "user-preflight-second-approver-one" + ) + ).scalar_one() + approval.approver_tenant_id = "preflight-other-workspace" + membership.tenant_id = "preflight-other-workspace" + mismatched_approver = client.get(path, headers=identity_headers()) + assert mismatched_approver.status_code == 200 + assert mismatched_approver.json()["ready"] is False + assert "approver_not_current" in { + item["code"] for item in mismatched_approver.json()["blockers"] + } + + with repository._session_factory.begin() as session: + approver = session.get(TenantUserModel, "user-preflight-second-approver-one") + assert approver is not None + approver.tenant_id = "preflight-other-workspace" + active_other_workspace = client.get(path, headers=identity_headers()) + assert active_other_workspace.status_code == 200 + assert active_other_workspace.json()["ready"] is True + + with repository._session_factory.begin() as session: + workspace = session.get(TenantModel, "preflight-other-workspace") + assert workspace is not None + workspace.status = "suspended" + suspended_approver_workspace = client.get(path, headers=identity_headers()) + assert suspended_approver_workspace.status_code == 200 + assert suspended_approver_workspace.json()["ready"] is False + assert { + item["code"] for item in suspended_approver_workspace.json()["blockers"] + } >= {"approver_not_current", "approval_quorum_missing"} + + with repository._session_factory.begin() as session: + approval = session.execute( + select(PrivilegedIdentityOperationApprovalModel).where( + PrivilegedIdentityOperationApprovalModel.operation_id + == "operation-preflight-002", + PrivilegedIdentityOperationApprovalModel.approver_user_id + == "user-preflight-second-approver-one", + ) + ).scalar_one() + membership = session.execute( + select(WorkspaceMembershipModel).where( + WorkspaceMembershipModel.tenant_user_id + == "user-preflight-second-approver-one" + ) + ).scalar_one() + approval.approver_tenant_id = "default" + membership.tenant_id = "default" + approver = session.get(TenantUserModel, "user-preflight-second-approver-one") + assert approver is not None + approver.tenant_id = "default" + workspace = session.get(TenantModel, "preflight-other-workspace") + assert workspace is not None + workspace.status = "active" + + with repository._session_factory.begin() as session: + approver = session.get(TenantUserModel, "user-preflight-second-approver-one") + assert approver is not None + approver.platform_permissions = [] + approver.updated_at = datetime.now(UTC) + timedelta(seconds=10) + stale_approval = client.get(path, headers=identity_headers()) + assert stale_approval.status_code == 200 + assert stale_approval.json()["ready"] is False + assert {item["code"] for item in stale_approval.json()["blockers"]} >= { + "approver_not_current", + "approval_quorum_missing", + } + + with repository._session_factory.begin() as session: + model = session.get(PrivilegedIdentityOperationModel, "operation-preflight-002") + assert model is not None + model.preview_expires_at = None + model.expires_at = datetime.now(UTC) - timedelta(seconds=1) + before = governance_storage_snapshot(repository) + expired = client.get(path, headers=identity_headers()) + after = governance_storage_snapshot(repository) + assert expired.status_code == 200 + assert before == after + expired_payload = expired.json() + assert expired_payload["stored_status"] == "approved" + assert expired_payload["effective_status"] == "expired" + assert expired_payload["ready"] is False + assert {item["code"] for item in expired_payload["blockers"]} >= { + "operation_expired", + "preview_verification_material_missing", + } + + +def test_execution_preflight_fails_closed_for_incomplete_revision_material( + tmp_path: Path, +) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + repository = app.state.contract_repository.identity + add_identity( + repository, + account_id="account-preflight-revision-target", + user_id="user-preflight-revision-target", + email="preflight-revision-target@example.com", + platform_admin=False, + ) + for suffix in ("one", "two"): + add_identity( + repository, + account_id=f"account-preflight-revision-approver-{suffix}", + user_id=f"user-preflight-revision-approver-{suffix}", + email=f"preflight-revision-approver-{suffix}@example.com", + platform_admin=True, + ) + preview = grant_preview(client, user_id="user-preflight-revision-target") + operation = submit_from_preview( + client, + preview, + operation_id="operation-preflight-revision-001", + ) + decision = { + "decision": "approve", + "comment": "Revision material preflight approval", + "preview_hash": operation["preview_hash"], + } + for suffix in ("one", "two"): + response = client.post( + "/api/v1/identity/privileged-operations/" + "operation-preflight-revision-001/approvals", + headers=identity_headers(f"user-preflight-revision-approver-{suffix}"), + json=decision, + ) + assert response.status_code == 200, response.text + + path = ( + "/api/v1/identity/privileged-operations/operation-preflight-revision-001/" + "execution-preflight" + ) + with repository._session_factory() as session: + model = session.get( + PrivilegedIdentityOperationModel, + "operation-preflight-revision-001", + ) + assert model is not None + original_revisions = [dict(item) for item in model.expected_revisions] + + cases = ( + ([], {"expected_revisions_missing", "expected_revisions_mismatch"}), + ( + original_revisions + [dict(original_revisions[0])], + {"expected_revisions_invalid", "expected_revisions_mismatch"}, + ), + ( + [{"entity_type": "tenant_user"}], + {"expected_revisions_invalid", "expected_revisions_mismatch"}, + ), + ) + for stored_revisions, expected_blockers in cases: + with repository._session_factory.begin() as session: + model = session.get( + PrivilegedIdentityOperationModel, + "operation-preflight-revision-001", + ) + assert model is not None + model.expected_revisions = stored_revisions + before = governance_storage_snapshot(repository) + response = client.get(path, headers=identity_headers()) + after = governance_storage_snapshot(repository) + assert response.status_code == 200, response.text + assert before == after + assert response.json()["ready"] is False + assert {item["code"] for item in response.json()["blockers"]} >= expected_blockers + + for malformed_evidence_refs in ( + [{"type": "account_conflict_diagnostic"}], + None, + {}, + "", + ): + with repository._session_factory.begin() as session: + model = session.get( + PrivilegedIdentityOperationModel, + "operation-preflight-revision-001", + ) + assert model is not None + model.expected_revisions = original_revisions + model.evidence_refs = malformed_evidence_refs + before = governance_storage_snapshot(repository) + invalid_preview_material = client.get(path, headers=identity_headers()) + after = governance_storage_snapshot(repository) + assert invalid_preview_material.status_code == 200, invalid_preview_material.text + assert before == after + assert invalid_preview_material.json()["ready"] is False + assert "preview_verification_material_invalid" in { + item["code"] for item in invalid_preview_material.json()["blockers"] + } + + +def test_execution_preflight_account_merge_covers_credential_and_workspace_locks( + tmp_path: Path, +) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + repository = app.state.contract_repository.identity + for suffix in ("source", "target"): + add_identity( + repository, + account_id=f"account-preflight-merge-{suffix}", + user_id=f"user-preflight-merge-{suffix}", + email=f"preflight-merge-{suffix}@example.com", + platform_admin=False, + ) + for suffix in ("one", "two"): + add_identity( + repository, + account_id=f"account-preflight-merge-approver-{suffix}", + user_id=f"user-preflight-merge-approver-{suffix}", + email=f"preflight-merge-approver-{suffix}@example.com", + platform_admin=True, + ) + + now = datetime.now(UTC) + with repository._session_factory.begin() as session: + for suffix in ("source", "target"): + session.add( + GlobalAccountCredentialModel( + id=f"global-credential-preflight-merge-{suffix}", + account_id=f"account-preflight-merge-{suffix}", + password_hash=f"global-test-hash-{suffix}", + password_scheme="test", + password_updated_at=now, + status="active", + created_at=now, + updated_at=now, + ) + ) + session.add( + TenantUserCredentialModel( + id=f"tenant-credential-preflight-merge-{suffix}", + tenant_id="default", + user_id=f"user-preflight-merge-{suffix}", + email=f"legacy-preflight-merge-{suffix}@example.com", + password_hash=f"tenant-test-hash-{suffix}", + password_scheme="test", + password_updated_at=now, + password_reset_required=False, + email_verified_at=now, + created_by="privileged-operation-test", + updated_by="privileged-operation-test", + deleted_at=None, + created_at=now, + updated_at=now, + ) + ) + + preview_response = client.post( + "/api/v1/identity/privileged-operations/preview", + headers=identity_headers(), + json={ + "operation_type": "account_merge", + "target_refs": { + "source_account_id": "account-preflight-merge-source", + "target_account_id": "account-preflight-merge-target", + }, + "evidence_refs": [], + }, + ) + assert preview_response.status_code == 200, preview_response.text + operation = submit_from_preview( + client, + preview_response.json(), + operation_id="operation-preflight-merge-001", + ) + decision = { + "decision": "approve", + "comment": "Account merge preflight approval", + "preview_hash": operation["preview_hash"], + } + for suffix in ("one", "two"): + response = client.post( + "/api/v1/identity/privileged-operations/" + "operation-preflight-merge-001/approvals", + headers=identity_headers(f"user-preflight-merge-approver-{suffix}"), + json=decision, + ) + assert response.status_code == 200, response.text + + before = governance_storage_snapshot(repository) + response = client.get( + "/api/v1/identity/privileged-operations/operation-preflight-merge-001/" + "execution-preflight", + headers=identity_headers(), + ) + after = governance_storage_snapshot(repository) + assert response.status_code == 200, response.text + assert before == after + payload = response.json() + assert payload["ready"] is True + assert [item["resource_type"] for item in payload["lock_plan"]] == [ + "privileged_operation", + "privileged_approval_set", + "identity_account", + "identity_account", + "workspace", + "tenant_user", + "tenant_user", + "workspace_membership", + "workspace_membership", + "identity_account_credential", + "identity_account_credential", + "tenant_user_credential", + "tenant_user_credential", + ] + assert all(item["matches"] for item in payload["revisions"]) + assert "test-hash" not in json.dumps(payload) + + def test_same_approver_concurrent_decisions_are_idempotent(tmp_path: Path) -> None: app = create_app(make_settings(tmp_path)) client = TestClient(app) @@ -887,6 +1451,9 @@ def test_privileged_operation_routes_are_s3_sensitive() -> None: ("GET", "/api/v1/identity/privileged-operations/op-001"), ("GET", "/api/v1/identity/privileged-operations/op-001/events"), ("GET", "/api/v1/identity/privileged-operations/op-001/delivery-status"), + ("GET", "/api/v1/identity/privileged-operations/op-001/execution-preflight"), + ("GET", "/api/v1/identity/privileged-approval-sla/overview"), + ("GET", "/api/v1/identity/privileged-approval-sla/queue"), ): policy = match_policy(method, path, policies) assert policy is not None @@ -896,6 +1463,15 @@ def test_privileged_operation_routes_are_s3_sensitive() -> None: def test_no_execution_or_destructive_identity_route_is_registered(tmp_path: Path) -> None: openapi_paths = create_app(make_settings(tmp_path)).openapi()["paths"] + preflight_path = ( + "/api/v1/identity/privileged-operations/{operation_id}/execution-preflight" + ) + assert preflight_path in openapi_paths + assert set(openapi_paths[preflight_path]) >= {"get"} + assert not any( + method in openapi_paths[preflight_path] + for method in ("post", "put", "patch", "delete") + ) for path in ( "/api/v1/identity/privileged-outbox/overview", "/api/v1/identity/privileged-outbox/dead-letters", @@ -934,6 +1510,286 @@ def test_no_execution_or_destructive_identity_route_is_registered(tmp_path: Path assert destructive_write_routes == [] +def test_approval_sla_overview_and_queue_are_read_only_minimally_disclosed_and_stable( + tmp_path: Path, +) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + repository = app.state.contract_repository.identity + targets = ( + ("breached", -30), + ("due", 60), + ("ontrack", 300), + ("outside", -300), + ) + operations: dict[str, dict] = {} + for label, _ in targets: + add_identity( + repository, + account_id=f"account-sla-{label}", + user_id=f"user-sla-{label}", + email=f"sla-{label}@example.com", + platform_admin=False, + ) + preview = grant_preview(client, user_id=f"user-sla-{label}") + operations[label] = submit_from_preview( + client, + preview, + operation_id=f"operation-sla-{label}-001", + reason="SLA queue read-only test request", + ) + + now = datetime.now(UTC) + with repository._session_factory.begin() as session: + for label, seconds in targets: + model = session.get( + PrivilegedIdentityOperationModel, + operations[label]["operation_id"], + ) + assert model is not None + model.expires_at = now + timedelta(seconds=seconds) + outside = session.get( + PrivilegedIdentityOperationModel, + operations["outside"]["operation_id"], + ) + assert outside is not None + outside.created_at = now - timedelta(days=2) + + forbidden = client.get( + "/api/v1/identity/privileged-approval-sla/overview", + headers=identity_headers("user-sla-breached"), + params={"due_soon_seconds": 60, "window_days": 1}, + ) + forbidden_queue = client.get( + "/api/v1/identity/privileged-approval-sla/queue", + headers=identity_headers("user-sla-breached"), + params={"due_soon_seconds": 60, "window_days": 1}, + ) + assert forbidden.status_code == 403 + assert forbidden_queue.status_code == 403 + assert forbidden.json()["detail"]["code"] == "platform_admin_required" + assert forbidden_queue.json()["detail"]["code"] == "platform_admin_required" + + before = governance_storage_snapshot(repository) + overview = client.get( + "/api/v1/identity/privileged-approval-sla/overview", + headers=identity_headers(), + params={"due_soon_seconds": 60, "window_days": 1}, + ) + queue = client.get( + "/api/v1/identity/privileged-approval-sla/queue", + headers=identity_headers(), + params={"due_soon_seconds": 60, "window_days": 1, "page_size": 2}, + ) + queue_page_two = client.get( + "/api/v1/identity/privileged-approval-sla/queue", + headers=identity_headers(), + params={"due_soon_seconds": 60, "window_days": 1, "page": 2, "page_size": 2}, + ) + all_queue = client.get( + "/api/v1/identity/privileged-approval-sla/queue", + headers=identity_headers(), + params={"due_soon_seconds": 60, "window_days": 1, "scope": "all", "page_size": 2}, + ) + all_queue_page_two = client.get( + "/api/v1/identity/privileged-approval-sla/queue", + headers=identity_headers(), + params={ + "due_soon_seconds": 60, + "window_days": 1, + "scope": "all", + "page": 2, + "page_size": 2, + }, + ) + all_breached = client.get( + "/api/v1/identity/privileged-approval-sla/queue", + headers=identity_headers(), + params={ + "due_soon_seconds": 60, + "window_days": 1, + "scope": "all", + "sla_status": "breached", + "page_size": 100, + }, + ) + after = governance_storage_snapshot(repository) + + assert overview.status_code == 200, overview.text + assert queue.status_code == 200, queue.text + assert queue_page_two.status_code == 200, queue_page_two.text + assert all_queue.status_code == 200, all_queue.text + assert all_queue_page_two.status_code == 200, all_queue_page_two.text + assert all_breached.status_code == 200, all_breached.text + assert before == after + assert overview.json()["total"] == 3 + assert overview.json()["counts"] == {"on_track": 1, "due_soon": 1, "breached": 1} + assert overview.json()["all_pending_total"] == 4 + assert overview.json()["outside_window_pending_count"] == 1 + assert overview.json()["outside_window_breached_count"] == 1 + assert overview.json()["oldest_pending_age_seconds"] >= 2 * 24 * 60 * 60 + assert queue.json()["total"] == 3 + assert queue.json()["scope"] == "window" + assert queue.json()["total_pages"] == 2 + assert [item["sla_status"] for item in queue.json()["items"]] == ["breached", "due_soon"] + assert [item["sla_status"] for item in queue_page_two.json()["items"]] == ["on_track"] + assert queue.json()["items"][0]["operation_id"] == operations["breached"]["operation_id"] + assert queue.json()["items"][1]["operation_id"] == operations["due"]["operation_id"] + assert queue.json()["items"][0]["valid_approval_count"] == 0 + assert queue.json()["items"][0]["remaining_approval_count"] == 2 + assert queue.json()["items"][0]["seconds_until_due"] < 0 + assert all_queue.json()["scope"] == "all" + assert all_queue.json()["total"] == 4 + assert all_queue.json()["total_pages"] == 2 + all_items = all_queue.json()["items"] + all_queue_page_two.json()["items"] + assert [item["operation_id"] for item in all_items] == [ + operations["outside"]["operation_id"], + operations["breached"]["operation_id"], + operations["due"]["operation_id"], + operations["ontrack"]["operation_id"], + ] + assert [item["operation_id"] for item in all_breached.json()["items"]] == [ + operations["outside"]["operation_id"], + operations["breached"]["operation_id"], + ] + serialized = queue.text + all_queue.text + all_queue_page_two.text + overview.text + for forbidden_key in ( + "reason", + "ticket_reference", + "target_refs", + "evidence_refs", + "actor_account_id", + "approvals", + "comment", + "email", + ): + assert forbidden_key not in serialized + + +def test_approval_sla_query_boundaries_and_routes_are_read_only(tmp_path: Path) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + headers = identity_headers() + + empty_overview = client.get( + "/api/v1/identity/privileged-approval-sla/overview", + headers=headers, + ) + empty_queue = client.get( + "/api/v1/identity/privileged-approval-sla/queue", + headers=headers, + params={"scope": "all"}, + ) + assert empty_overview.status_code == 200 + assert empty_overview.json()["total"] == 0 + assert empty_overview.json()["all_pending_total"] == 0 + assert empty_overview.json()["outside_window_pending_count"] == 0 + assert empty_overview.json()["outside_window_breached_count"] == 0 + assert empty_overview.json()["oldest_pending_age_seconds"] is None + assert empty_queue.status_code == 200 + assert empty_queue.json()["scope"] == "all" + assert empty_queue.json()["items"] == [] + + for params in ( + {"due_soon_seconds": 59}, + {"due_soon_seconds": 86_401}, + {"window_days": 0}, + {"window_days": 32}, + {"scope": "unsupported"}, + ): + response = client.get( + "/api/v1/identity/privileged-approval-sla/queue", + headers=headers, + params=params, + ) + assert response.status_code == 422 + + paths = create_app(make_settings(tmp_path / "openapi")).openapi()["paths"] + for path in ( + "/api/v1/identity/privileged-approval-sla/overview", + "/api/v1/identity/privileged-approval-sla/queue", + ): + assert set(paths[path]) == {"get"} + assert not any("approval-sla" in path and method != "get" for path, operations in paths.items() for method in operations) + + +def test_approval_sla_counts_only_current_unexpired_revision_bound_approvals( + tmp_path: Path, +) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + repository = app.state.contract_repository.identity + cases = ("valid", "expired", "preview", "revision", "not-current") + operation_ids: dict[str, str] = {} + for label in cases: + add_identity( + repository, + account_id=f"account-sla-count-target-{label}", + user_id=f"user-sla-count-target-{label}", + email=f"sla-count-target-{label}@example.com", + platform_admin=False, + ) + add_identity( + repository, + account_id=f"account-sla-count-approver-{label}", + user_id=f"user-sla-count-approver-{label}", + email=f"sla-count-approver-{label}@example.com", + platform_admin=True, + ) + preview = grant_preview(client, user_id=f"user-sla-count-target-{label}") + operation_id = f"operation-sla-count-{label}-001" + operation = submit_from_preview(client, preview, operation_id=operation_id) + approval = client.post( + f"/api/v1/identity/privileged-operations/{operation_id}/approvals", + headers=identity_headers(f"user-sla-count-approver-{label}"), + json={ + "decision": "approve", + "comment": "SLA validity test approval", + "preview_hash": operation["preview_hash"], + }, + ) + assert approval.status_code == 200, approval.text + operation_ids[label] = operation_id + + with repository._session_factory.begin() as session: + approvals = { + item.operation_id: item + for item in session.execute( + select(PrivilegedIdentityOperationApprovalModel).where( + PrivilegedIdentityOperationApprovalModel.operation_id.in_( + operation_ids.values() + ) + ) + ).scalars() + } + approvals[operation_ids["expired"]].expires_at = datetime.now(UTC) - timedelta(seconds=1) + approvals[operation_ids["preview"]].preview_hash = "different-preview-hash" + approvals[operation_ids["revision"]].permission_revision = "different-permission-revision" + no_longer_admin = session.get( + TenantUserModel, + "user-sla-count-approver-not-current", + ) + assert no_longer_admin is not None + no_longer_admin.platform_permissions = [] + + before = governance_storage_snapshot(repository) + response = client.get( + "/api/v1/identity/privileged-approval-sla/queue", + headers=identity_headers(), + params={"window_days": 1, "page_size": 100}, + ) + after = governance_storage_snapshot(repository) + assert response.status_code == 200, response.text + assert before == after + counts = { + item["operation_id"]: item["valid_approval_count"] + for item in response.json()["items"] + } + assert counts[operation_ids["valid"]] == 1 + for label in ("expired", "preview", "revision", "not-current"): + assert counts[operation_ids[label]] == 0 + + def test_outbox_failure_rolls_back_operation_and_event( tmp_path: Path, monkeypatch, @@ -1354,7 +2210,7 @@ def test_privileged_outbox_overview_dead_letters_and_gets_are_strictly_read_only for item in first_page["items"] + second_page_response.json()["items"] } == { item[0] - for item in before[2] + for item in before[3] if item[1].startswith("operation-observe-dead-") } assert filtered_response.status_code == 200 @@ -1503,6 +2359,132 @@ def test_outbox_worker_uses_leases_backoff_and_dead_letter(tmp_path: Path) -> No assert model.last_error_code == "transport_error" +def test_outbox_worker_requires_contract_before_claim_and_maps_typed_receipts(tmp_path: Path) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + identity_repository = app.state.contract_repository.identity + add_identity( + identity_repository, + account_id="account-delivery-contract-target", + user_id="user-delivery-contract-target", + email="delivery-contract-target@example.com", + platform_admin=False, + ) + preview = grant_preview(client, user_id="user-delivery-contract-target") + operation = submit_from_preview( + client, + preview, + operation_id="operation-delivery-contract-001", + ) + outbox = PrivilegedIdentityOutboxRepository(identity_repository._session_factory) + contract = PrivilegedIdentityDeliveryContract( + key_id="worker-delivery-contract-key", + signing_secret=bytes([31]) * 32, + delivery_key_secret=bytes([47]) * 32, + ) + retry_attempts = 0 + + def retry_once(_envelope) -> None: + nonlocal retry_attempts + retry_attempts += 1 + if retry_attempts == 1: + raise RetryablePrivilegedIdentityDeliveryError("temporary dependency failure") + + transport = InMemoryPrivilegedIdentityConsumer( + "typed-receipt-consumer", + {contract.key_id: contract}, + apply=retry_once, + ) + missing_contract_worker = PrivilegedIdentityOutboxWorker( + outbox, + worker_id="missing-contract-worker", + transport=transport, + ) + with pytest.raises(RuntimeError, match="requires a delivery contract"): + missing_contract_worker.run_once(batch_size=1) + with identity_repository._session_factory() as session: + queued = session.execute( + select(PrivilegedIdentityOutboxMessageModel).where( + PrivilegedIdentityOutboxMessageModel.operation_id + == operation["operation_id"] + ) + ).scalar_one() + assert queued.status == "queued" + assert queued.attempt_count == 0 + + retrying_worker = PrivilegedIdentityOutboxWorker( + outbox, + worker_id="typed-receipt-worker-one", + transport=transport, + delivery_contract=contract, + ) + assert retrying_worker.run_once(batch_size=1) == 0 + with identity_repository._session_factory.begin() as session: + retrying = session.execute( + select(PrivilegedIdentityOutboxMessageModel).where( + PrivilegedIdentityOutboxMessageModel.operation_id + == operation["operation_id"] + ) + ).scalar_one() + assert retrying.status == "retry_scheduled" + assert retrying.last_error_code == "consumer_unavailable" + retrying.available_at = datetime.now(UTC) - timedelta(seconds=1) + + recovery_worker = PrivilegedIdentityOutboxWorker( + outbox, + worker_id="typed-receipt-worker-two", + transport=transport, + delivery_contract=contract, + ) + assert recovery_worker.run_once(batch_size=1) == 1 + with identity_repository._session_factory() as session: + delivered = session.execute( + select(PrivilegedIdentityOutboxMessageModel).where( + PrivilegedIdentityOutboxMessageModel.operation_id + == operation["operation_id"] + ) + ).scalar_one() + assert delivered.status == "delivered" + assert delivered.last_error_code is None + assert delivered.attempt_count == 2 + assert retry_attempts == 2 + + mismatch_preview = grant_preview(client, user_id="user-delivery-contract-target") + mismatch_operation = submit_from_preview( + client, + mismatch_preview, + operation_id="operation-delivery-contract-002", + ) + + class MismatchedReceiptTransport: + enabled = True + + @staticmethod + def deliver(envelope) -> PrivilegedIdentityDeliveryReceipt: + return PrivilegedIdentityDeliveryReceipt( + delivery_key=envelope.delivery_key, + body_digest="0" * 64, + disposition="accepted", + ) + + mismatch_worker = PrivilegedIdentityOutboxWorker( + outbox, + worker_id="mismatched-receipt-worker", + transport=MismatchedReceiptTransport(), + delivery_contract=contract, + ) + assert mismatch_worker.run_once(batch_size=1) == 0 + with identity_repository._session_factory() as session: + mismatched = session.execute( + select(PrivilegedIdentityOutboxMessageModel).where( + PrivilegedIdentityOutboxMessageModel.operation_id + == mismatch_operation["operation_id"] + ) + ).scalar_one() + assert mismatched.status == "dead_letter" + assert mismatched.last_error_code == "receipt_mismatch" + + def test_sqlite_outbox_worker_rejects_a_second_concurrent_worker(tmp_path: Path) -> None: app = create_app(make_settings(tmp_path)) client = TestClient(app) @@ -1517,20 +2499,43 @@ def test_sqlite_outbox_worker_rejects_a_second_concurrent_worker(tmp_path: Path) preview = grant_preview(client) submit_from_preview(client, preview, operation_id="operation-single-worker-001") outbox = PrivilegedIdentityOutboxRepository(identity_repository._session_factory) - first_worker = PrivilegedIdentityOutboxWorker(outbox, worker_id="sqlite-worker-one") - second_worker = PrivilegedIdentityOutboxWorker(outbox, worker_id="sqlite-worker-two") delivery_started = Event() release_delivery = Event() - def blocking_delivery(_message) -> None: + def blocking_delivery(_envelope) -> None: delivery_started.set() assert release_delivery.wait(timeout=5) + contract = PrivilegedIdentityDeliveryContract( + key_id="sqlite-worker-test-key", + signing_secret=bytes([59]) * 32, + delivery_key_secret=bytes([61]) * 32, + ) + first_worker = PrivilegedIdentityOutboxWorker( + outbox, + worker_id="sqlite-worker-one", + transport=InMemoryPrivilegedIdentityConsumer( + "sqlite-blocking-consumer", + {contract.key_id: contract}, + apply=blocking_delivery, + ), + delivery_contract=contract, + ) + second_worker = PrivilegedIdentityOutboxWorker( + outbox, + worker_id="sqlite-worker-two", + transport=InMemoryPrivilegedIdentityConsumer( + "sqlite-second-consumer", + {contract.key_id: contract}, + ), + delivery_contract=contract, + ) + with ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit(first_worker.run_once, blocking_delivery, batch_size=1) + future = executor.submit(first_worker.run_once, batch_size=1) assert delivery_started.wait(timeout=5) with pytest.raises(RuntimeError, match="one worker only"): - second_worker.run_once(lambda _message: None, batch_size=1) + second_worker.run_once(batch_size=1) release_delivery.set() assert future.result(timeout=5) == 1 @@ -1610,3 +2615,384 @@ def test_expired_outbox_lease_cannot_acknowledge_and_can_be_reclaimed(tmp_path: worker_id="lease-worker-two", now=after_expiry + timedelta(seconds=1), ) is True + + +def test_execution_attempt_ledger_is_internal_append_only_and_minimally_disclosed( + tmp_path: Path, +) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + identity_repository = app.state.contract_repository.identity + attempts = identity_repository.privileged_operations + add_identity( + identity_repository, + account_id="account-attempt-target", + user_id="user-attempt-target", + email="attempt-target@example.com", + platform_admin=False, + ) + for suffix in ("one", "two"): + add_identity( + identity_repository, + account_id=f"account-attempt-approver-{suffix}", + user_id=f"user-attempt-approver-{suffix}", + email=f"attempt-approver-{suffix}@example.com", + platform_admin=True, + ) + + preview = grant_preview(client, user_id="user-attempt-target") + operation = submit_from_preview( + client, + preview, + operation_id="operation-execution-attempt-001", + ) + decision = { + "decision": "approve", + "comment": "Execution attempt ledger approval verified", + "preview_hash": operation["preview_hash"], + } + for suffix in ("one", "two"): + response = client.post( + "/api/v1/identity/privileged-operations/operation-execution-attempt-001/approvals", + headers=identity_headers(f"user-attempt-approver-{suffix}"), + json=decision, + ) + assert response.status_code == 200, response.text + + with identity_repository._session_factory() as session: + stored_operation = session.get( + PrivilegedIdentityOperationModel, + "operation-execution-attempt-001", + ) + assert stored_operation is not None + request_hash = stored_operation.request_payload_hash + preflight_hash = privileged_payload_hash( + { + "operation_id": operation["operation_id"], + "preview_hash": operation["preview_hash"], + "ready": True, + "lock_plan_version": 1, + } + ) + governance_before = governance_storage_snapshot(identity_repository) + registered = attempts.register_execution_attempt( + operation["operation_id"], + idempotency_key="execution-attempt-idempotency-001", + request_hash=request_hash, + preview_hash=operation["preview_hash"], + preflight_hash=preflight_hash, + trace_id="trace-execution-attempt-001", + ) + replayed = attempts.register_execution_attempt( + operation["operation_id"], + idempotency_key="execution-attempt-idempotency-001", + request_hash=request_hash, + preview_hash=operation["preview_hash"], + preflight_hash=preflight_hash, + trace_id="trace-replay-must-not-rewrite", + ) + assert replayed == registered + assert registered.status == "prepared" + assert registered.attempt_no == 1 + assert registered.event_count == 1 + assert governance_storage_snapshot(identity_repository) == governance_before + + with pytest.raises(PrivilegedIdentityOperationError) as idempotency_conflict: + attempts.register_execution_attempt( + operation["operation_id"], + idempotency_key="execution-attempt-idempotency-001", + request_hash=request_hash, + preview_hash=operation["preview_hash"], + preflight_hash="f" * 64, + ) + assert idempotency_conflict.value.status_code == 409 + assert idempotency_conflict.value.code == "execution_attempt_idempotency_conflict" + + started = attempts.append_execution_attempt_event( + registered.attempt_id, + event_type="attempt_started", + status_before="prepared", + status_after="running", + snapshot_before={"status": "prepared", "attempt_no": 1}, + snapshot_after={"status": "running", "attempt_no": 1, "step_count": 0}, + trace_id="trace-execution-started-001", + ) + replayed_started = attempts.append_execution_attempt_event( + registered.attempt_id, + event_type="attempt_started", + status_before="prepared", + status_after="running", + snapshot_before={"status": "prepared", "attempt_no": 1}, + snapshot_after={"status": "running", "attempt_no": 1, "step_count": 0}, + trace_id="trace-replay-must-not-rewrite", + ) + assert replayed_started == started + assert started.status == "running" + assert started.event_count == 2 + + failed = attempts.append_execution_attempt_event( + registered.attempt_id, + event_type="attempt_failed", + status_before="running", + status_after="failed", + error_code="provider_timeout", + snapshot_before={"status": "running", "attempt_no": 1, "step_count": 0}, + snapshot_after={ + "status": "failed", + "attempt_no": 1, + "step_count": 1, + "result_code": "provider.timeout", + "retryable": True, + }, + trace_id="trace-execution-failed-001", + ) + assert failed.status == "failed" + assert failed.last_error_code == "provider_timeout" + assert failed.event_count == 3 + + with pytest.raises(PrivilegedIdentityOperationError) as terminal_transition: + attempts.append_execution_attempt_event( + registered.attempt_id, + event_type="compensation_required", + status_before="failed", + status_after="compensation_required", + snapshot_before={"status": "failed"}, + snapshot_after={"status": "compensation_required"}, + ) + assert terminal_transition.value.code == "execution_attempt_transition_invalid" + + with pytest.raises(PrivilegedIdentityOperationError) as raw_error: + attempts.append_execution_attempt_event( + registered.attempt_id, + event_type="attempt_failed", + status_before="running", + status_after="failed", + error_code="password=secret; stack trace follows", + snapshot_before={"status": "running"}, + snapshot_after={"status": "failed"}, + ) + assert raw_error.value.code == "execution_attempt_error_code_invalid" + + with pytest.raises(PrivilegedIdentityOperationError) as sensitive_snapshot: + attempts.append_execution_attempt_event( + registered.attempt_id, + event_type="attempt_failed", + status_before="running", + status_after="failed", + error_code="unknown", + snapshot_before={"status": "running", "target_email": "secret@example.com"}, + snapshot_after={"status": "failed"}, + ) + assert sensitive_snapshot.value.code == "execution_attempt_snapshot_invalid" + + ledger_before_read = execution_ledger_snapshot(identity_repository) + governance_before_read = governance_storage_snapshot(identity_repository) + response = client.get( + "/api/v1/identity/privileged-operations/operation-execution-attempt-001/execution-attempts", + headers=identity_headers(), + ) + assert response.status_code == 200, response.text + payload = response.json() + assert payload["executor_enabled"] is False + assert payload["total"] == 1 + assert set(payload["items"][0]) == { + "attempt_id", + "operation_id", + "attempt_no", + "status", + "event_count", + "last_error_code", + "trace_id", + "created_at", + "latest_event_at", + } + serialized = json.dumps(payload).lower() + for forbidden in ( + "execution-attempt-idempotency-001", + request_hash, + preflight_hash, + "snapshot_before", + "snapshot_after", + "secret@example.com", + "password=secret", + "target_refs", + "evidence_refs", + "actor_account_id", + ): + assert forbidden.lower() not in serialized + assert execution_ledger_snapshot(identity_repository) == ledger_before_read + assert governance_storage_snapshot(identity_repository) == governance_before_read + + forbidden = client.get( + "/api/v1/identity/privileged-operations/operation-execution-attempt-001/execution-attempts", + headers=identity_headers("user-attempt-target"), + ) + assert forbidden.status_code == 403 + missing = client.get( + "/api/v1/identity/privileged-operations/missing-operation/execution-attempts", + headers=identity_headers(), + ) + assert missing.status_code == 404 + + openapi = app.openapi() + attempt_path = ( + "/api/v1/identity/privileged-operations/{operation_id}/execution-attempts" + ) + assert set(openapi["paths"][attempt_path]) == {"get"} + destructive_fragments = ("execute", "rollback", "retry", "compensate", "abort") + assert not any( + "/identity/privileged" in path.lower() + and method.lower() in {"post", "put", "patch", "delete"} + and any(fragment in path.lower() for fragment in destructive_fragments) + for path, methods in openapi["paths"].items() + for method in methods + ) + + with identity_repository._session_factory.begin() as session: + root = session.get(PrivilegedIdentityExecutionAttemptModel, registered.attempt_id) + assert root is not None + root.trace_id = "trace-forbidden-update" + with pytest.raises(DBAPIError): + session.flush() + with identity_repository._session_factory.begin() as session: + event = session.execute( + select(PrivilegedIdentityExecutionAttemptEventModel).where( + PrivilegedIdentityExecutionAttemptEventModel.attempt_id + == registered.attempt_id + ) + ).scalars().first() + assert event is not None + session.delete(event) + with pytest.raises(DBAPIError): + session.flush() + + +def test_execution_attempt_compensation_and_abort_paths_are_exact( + tmp_path: Path, +) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + identity_repository = app.state.contract_repository.identity + attempts = identity_repository.privileged_operations + add_identity( + identity_repository, + account_id="account-attempt-path-target", + user_id="user-attempt-path-target", + email="attempt-path-target@example.com", + platform_admin=False, + ) + for suffix in ("one", "two"): + add_identity( + identity_repository, + account_id=f"account-attempt-path-approver-{suffix}", + user_id=f"user-attempt-path-approver-{suffix}", + email=f"attempt-path-approver-{suffix}@example.com", + platform_admin=True, + ) + preview = grant_preview(client, user_id="user-attempt-path-target") + operation = submit_from_preview( + client, + preview, + operation_id="operation-execution-attempt-paths-001", + ) + decision = { + "decision": "approve", + "comment": "Execution attempt transition paths verified", + "preview_hash": operation["preview_hash"], + } + for suffix in ("one", "two"): + response = client.post( + "/api/v1/identity/privileged-operations/operation-execution-attempt-paths-001/approvals", + headers=identity_headers(f"user-attempt-path-approver-{suffix}"), + json=decision, + ) + assert response.status_code == 200, response.text + with identity_repository._session_factory() as session: + stored_operation = session.get( + PrivilegedIdentityOperationModel, + operation["operation_id"], + ) + assert stored_operation is not None + request_hash = stored_operation.request_payload_hash + preflight_hash = privileged_payload_hash({"operation_id": operation["operation_id"], "ready": True}) + + compensation = attempts.register_execution_attempt( + operation["operation_id"], + idempotency_key="execution-attempt-compensation-001", + request_hash=request_hash, + preview_hash=operation["preview_hash"], + preflight_hash=preflight_hash, + ) + attempts.append_execution_attempt_event( + compensation.attempt_id, + event_type="attempt_started", + status_before="prepared", + status_after="running", + snapshot_before={"status": "prepared"}, + snapshot_after={"status": "running"}, + ) + needs_compensation = attempts.append_execution_attempt_event( + compensation.attempt_id, + event_type="compensation_required", + status_before="running", + status_after="compensation_required", + snapshot_before={"status": "running", "step_count": 1}, + snapshot_after={ + "status": "compensation_required", + "step_count": 1, + "compensation_required": True, + "result_code": "partial_apply", + }, + ) + assert needs_compensation.status == "compensation_required" + compensated = attempts.append_execution_attempt_event( + compensation.attempt_id, + event_type="attempt_compensated", + status_before="compensation_required", + status_after="compensated", + snapshot_before={"status": "compensation_required", "compensation_required": True}, + snapshot_after={"status": "compensated", "compensation_required": False}, + ) + assert compensated.status == "compensated" + assert compensated.event_count == 4 + + with pytest.raises(PrivilegedIdentityOperationError) as terminal: + attempts.append_execution_attempt_event( + compensation.attempt_id, + event_type="attempt_aborted", + status_before="compensated", + status_after="aborted", + snapshot_before={"status": "compensated"}, + snapshot_after={"status": "aborted"}, + ) + assert terminal.value.code == "execution_attempt_transition_invalid" + + aborted = attempts.register_execution_attempt( + operation["operation_id"], + idempotency_key="execution-attempt-aborted-001", + request_hash=request_hash, + preview_hash=operation["preview_hash"], + preflight_hash=preflight_hash, + ) + aborted = attempts.append_execution_attempt_event( + aborted.attempt_id, + event_type="attempt_aborted", + status_before="prepared", + status_after="aborted", + snapshot_before={"status": "prepared"}, + snapshot_after={"status": "aborted", "result_code": "operator_cancelled"}, + ) + assert aborted.status == "aborted" + assert aborted.attempt_no == 2 + assert aborted.event_count == 2 + + page = attempts.list_execution_attempts( + operation["operation_id"], + actor_tenant_id="default", + actor_user_id="user-current-admin", + ) + assert [(item.attempt_no, item.status) for item in page.items] == [ + (1, "compensated"), + (2, "aborted"), + ] + assert page.executor_enabled is False diff --git a/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_plans.py b/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_plans.py new file mode 100644 index 00000000..3663cdc5 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_plans.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import pytest + +from yuqei_contract_api.privileged_identity import ( + PrivilegedIdentityExpectedRevision, + PrivilegedIdentityPreflightLock, +) +from yuqei_contract_api.privileged_identity_plans import ( + PrivilegedIdentityPlanError, + build_privileged_identity_change_plan, + validate_privileged_identity_change_plan, +) + + +def _materials() -> tuple[list[PrivilegedIdentityExpectedRevision], list[PrivilegedIdentityPreflightLock]]: + revisions = [ + PrivilegedIdentityExpectedRevision( + entity_type="workspace_membership", + entity_id="membership-1", + revision="2026-08-09T01:00:00+00:00", + ), + PrivilegedIdentityExpectedRevision( + entity_type="identity_account", + entity_id="account-1", + revision="2026-08-09T00:00:00+00:00", + ), + ] + locks = [ + PrivilegedIdentityPreflightLock( + sequence=1, + resource_type="privileged_operation", + resource_id="operation-plan-001", + current_revision="2026-08-09T02:00:00+00:00", + matches=True, + ), + PrivilegedIdentityPreflightLock( + sequence=2, + resource_type="privileged_approval_set", + resource_id="operation-plan-001", + current_revision="approval-hash", + matches=True, + ), + PrivilegedIdentityPreflightLock( + sequence=3, + resource_type="identity_account", + resource_id="account-1", + expected_revision="2026-08-09T00:00:00+00:00", + current_revision="2026-08-09T00:00:00+00:00", + matches=True, + ), + PrivilegedIdentityPreflightLock( + sequence=4, + resource_type="workspace_membership", + resource_id="membership-1", + expected_revision="2026-08-09T01:00:00+00:00", + current_revision="2026-08-09T01:00:00+00:00", + matches=True, + ), + ] + return revisions, locks + + +def test_plan_hash_and_order_are_deterministic() -> None: + revisions, locks = _materials() + first = build_privileged_identity_change_plan( + "account_merge", + {"target_account_id": "account-2", "source_account_id": "account-1"}, + revisions, + locks, + ) + second = build_privileged_identity_change_plan( + "account_merge", + {"source_account_id": "account-1", "target_account_id": "account-2"}, + list(reversed(revisions)), + list(reversed(locks)), + ) + + assert first == second + assert first.steps[0].sequence == 1 + assert [item.sequence for item in first.compensation_steps] == [1, 2, 3] + assert first.compensation_steps[0].action == "restore_global_credentials" + assert validate_privileged_identity_change_plan(first) + + +@pytest.mark.parametrize( + ("operation_type", "target_refs", "expected_actions", "expected_compensation"), + [ + ( + "account_split", + { + "source_account_id": "account-1", + "destination_account_id": "account-2", + "membership_partition": {"membership-1": "account-2"}, + }, + ["apply_membership_partition", "reconcile_split_identity_accounts", "reconcile_split_credentials"], + ["restore_split_credentials", "restore_split_identity_accounts", "restore_membership_partition"], + ), + ( + "membership_recovery", + { + "account_id": "account-1", + "tenant_id": "workspace-1", + "tenant_user_id": "user-1", + "recovery_type": "relink", + }, + ["recover_workspace_membership", "restore_workspace_owner_consistency"], + ["restore_workspace_owner_consistency", "restore_workspace_membership_state"], + ), + ( + "platform_admin_grant", + {"target_tenant_id": "workspace-1", "target_user_id": "user-1"}, + ["grant_platform_admin_permission"], + ["revoke_platform_admin_permission"], + ), + ( + "platform_admin_revoke", + {"target_tenant_id": "workspace-1", "target_user_id": "user-1"}, + ["revoke_platform_admin_permission"], + ["grant_platform_admin_permission"], + ), + ], +) +def test_all_identity_operations_have_reverse_plans( + operation_type: str, + target_refs: dict[str, object], + expected_actions: list[str], + expected_compensation: list[str], +) -> None: + revisions, locks = _materials() + plan = build_privileged_identity_change_plan(operation_type, target_refs, revisions, locks) + assert [item.action for item in plan.steps] == expected_actions + assert [item.action for item in plan.compensation_steps] == expected_compensation + assert plan.plan_hash != plan.compensation_plan_hash + assert validate_privileged_identity_change_plan(plan) + + +def test_unknown_incomplete_sensitive_and_disabled_refs_fail_closed() -> None: + revisions, locks = _materials() + with pytest.raises(PrivilegedIdentityPlanError, match="Unknown"): + build_privileged_identity_change_plan( + "account_merge", + {"source_account_id": "a", "target_account_id": "b", "email": "a@example.com"}, + revisions, + locks, + ) + with pytest.raises(PrivilegedIdentityPlanError): + build_privileged_identity_change_plan( + "account_merge", {"source_account_id": "a"}, revisions, locks + ) + with pytest.raises(PrivilegedIdentityPlanError, match="Support access"): + build_privileged_identity_change_plan( + "support_access_grant", + { + "support_account_id": "a", + "tenant_id": "t", + "permission_scopes": ["contract.read"], + "expires_at": "2026-08-10T00:00:00+00:00", + "subject_reference": "sensitive", + }, + revisions, + locks, + ) + + +def test_stale_lock_plan_and_tampering_are_rejected() -> None: + revisions, locks = _materials() + stale = list(locks) + stale[-1] = stale[-1].model_copy(update={"matches": False}) + with pytest.raises(PrivilegedIdentityPlanError, match="stale"): + build_privileged_identity_change_plan( + "account_merge", + {"source_account_id": "account-1", "target_account_id": "account-2"}, + revisions, + stale, + ) + plan = build_privileged_identity_change_plan( + "account_merge", + {"source_account_id": "account-1", "target_account_id": "account-2"}, + revisions, + locks, + ) + tampered = plan.model_copy(update={"plan_hash": "0" * 64}) + assert not validate_privileged_identity_change_plan(tampered) + assert "sensitive@example.com" not in plan.model_dump_json() + + +def test_lock_plan_with_fewer_than_operation_and_approval_rows_fails_stably() -> None: + revisions, locks = _materials() + with pytest.raises(PrivilegedIdentityPlanError) as error: + build_privileged_identity_change_plan( + "account_merge", + {"source_account_id": "account-1", "target_account_id": "account-2"}, + revisions, + locks[:1], + ) + assert error.value.code == "lock_plan_scope_invalid" diff --git a/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_postgres_concurrency.py b/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_postgres_concurrency.py index 36efef39..8c2308ed 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_postgres_concurrency.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_postgres_concurrency.py @@ -14,7 +14,7 @@ from typing import Callable, Iterator, TypeVar from uuid import uuid4 import pytest -from sqlalchemy import create_engine, func, select, text +from sqlalchemy import create_engine, event as sqlalchemy_event, func, select, text from sqlalchemy.engine import URL, make_url from sqlalchemy.exc import DBAPIError from sqlalchemy.orm import Session, sessionmaker @@ -22,6 +22,8 @@ from sqlalchemy.orm import Session, sessionmaker from yuqei_contract_api.logging import set_trace_id from yuqei_contract_api.persistence.models import ( GlobalAccountModel, + PrivilegedIdentityExecutionAttemptEventModel, + PrivilegedIdentityExecutionAttemptModel, PrivilegedIdentityOperationApprovalModel, PrivilegedIdentityOperationEventModel, PrivilegedIdentityOperationModel, @@ -41,6 +43,9 @@ from yuqei_contract_api.privileged_identity import ( PrivilegedIdentityOperationSubmitRequest, privileged_payload_hash, ) +from yuqei_contract_api.privileged_identity_delivery import ( + PrivilegedIdentityDeliveryContract, +) _DATABASE_ENV = "CONTRACT_API_POSTGRES_CONCURRENCY_URL" @@ -64,32 +69,67 @@ def _isolated_url(base_url: str, schema_name: str, *, include_public: bool = Fal return url.set(query=query) -def _run_migrations(base_url: str, schema_name: str) -> None: +def _run_alembic( + base_url: str, + schema_name: str, + action: str, + target: str, + *, + include_public: bool = False, +) -> None: source_path = str(_SERVICE_ROOT / "src") - migration_phases = ( - ("20260624_0007", False), - ("20260624_0009", True), - ("head", False), + environment = os.environ.copy() + environment["CONTRACT_API_DATABASE_URL"] = _isolated_url( + base_url, + schema_name, + include_public=include_public, + ).render_as_string(hide_password=False) + environment["PYTHONPATH"] = os.pathsep.join( + item for item in (source_path, environment.get("PYTHONPATH", "")) if item ) - for target, include_public in migration_phases: - environment = os.environ.copy() - environment["CONTRACT_API_DATABASE_URL"] = _isolated_url( - base_url, - schema_name, - include_public=include_public, - ).render_as_string(hide_password=False) - environment["PYTHONPATH"] = os.pathsep.join( - item for item in (source_path, environment.get("PYTHONPATH", "")) if item - ) - result = subprocess.run( - [sys.executable, "-m", "alembic", "upgrade", target], - cwd=_SERVICE_ROOT, - env=environment, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - assert result.returncode == 0, f"Alembic upgrade {target} failed in the isolated PostgreSQL schema." + result = subprocess.run( + [sys.executable, "-m", "alembic", action, target], + cwd=_SERVICE_ROOT, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + assert result.returncode == 0, ( + f"Alembic {action} {target} failed in the isolated PostgreSQL schema: " + f"{result.stderr[-4000:]}" + ) + + +def _run_migrations(base_url: str, schema_name: str) -> None: + _run_alembic(base_url, schema_name, "upgrade", "20260624_0007") + _run_alembic( + base_url, + schema_name, + "upgrade", + "20260624_0009", + include_public=True, + ) + _run_alembic(base_url, schema_name, "upgrade", "head") + _run_alembic(base_url, schema_name, "downgrade", "20260803_0042") + probe_engine = create_engine(_isolated_url(base_url, schema_name), pool_pre_ping=True) + try: + with probe_engine.connect() as connection: + downgraded_tables = set( + connection.execute( + text( + "SELECT tablename FROM pg_tables " + "WHERE schemaname = :schema_name" + ), + {"schema_name": schema_name}, + ).scalars() + ) + assert "privileged_identity_execution_attempts" not in downgraded_tables + assert "privileged_identity_execution_attempt_events" not in downgraded_tables + finally: + probe_engine.dispose() + _run_alembic(base_url, schema_name, "upgrade", "head") @pytest.fixture(scope="module") @@ -122,7 +162,7 @@ def postgres_governance() -> Iterator[PostgresGovernanceHarness]: with isolated_engine.connect() as connection: assert connection.scalar( text("SELECT version_num FROM contract_api_alembic_version") - ) == "20260803_0041" + ) == "20260803_0043" migrated_tables = set( connection.execute( text("SELECT tablename FROM pg_tables WHERE schemaname = :schema_name"), @@ -136,7 +176,24 @@ def postgres_governance() -> Iterator[PostgresGovernanceHarness]: "privileged_identity_operation_approvals", "privileged_identity_operation_events", "privileged_identity_outbox_messages", + "privileged_identity_execution_attempts", + "privileged_identity_execution_attempt_events", } <= migrated_tables + operation_columns = { + row[0] + for row in connection.execute( + text( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = :schema_name + AND table_name = 'privileged_identity_operations' + """ + ), + {"schema_name": schema_name}, + ) + } + assert "preview_expires_at" in operation_columns outbox_constraints = { row[0] for row in connection.execute( @@ -173,6 +230,70 @@ def postgres_governance() -> Iterator[PostgresGovernanceHarness]: "ux_privileged_identity_operation_events_operation_event" in event_indexes ) + attempt_constraints = { + row[0] + for row in connection.execute( + text( + """ + SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_schema = :schema_name + AND table_name = 'privileged_identity_execution_attempts' + """ + ), + {"schema_name": schema_name}, + ) + } + assert { + "fk_privileged_identity_execution_attempts_operation", + "ck_privileged_identity_execution_attempts_number", + "ux_privileged_identity_execution_attempts_operation_number", + "ux_privileged_identity_execution_attempts_idempotency_key", + } <= attempt_constraints + attempt_event_constraints = { + row[0] + for row in connection.execute( + text( + """ + SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_schema = :schema_name + AND table_name = 'privileged_identity_execution_attempt_events' + """ + ), + {"schema_name": schema_name}, + ) + } + assert { + "fk_privileged_identity_execution_attempt_events_attempt", + "ck_privileged_identity_execution_attempt_events_type", + "ck_privileged_identity_execution_attempt_events_status_after", + "ck_privileged_identity_execution_attempt_events_status_before", + "ck_privileged_identity_execution_attempt_events_sequence", + "ux_priv_id_exec_attempt_events_attempt_sequence", + "ux_priv_id_exec_attempt_events_attempt_payload", + } <= attempt_event_constraints + execution_triggers = { + row[0] + for row in connection.execute( + text( + """ + SELECT trigger_name + FROM information_schema.triggers + WHERE trigger_schema = :schema_name + AND event_object_table IN ( + 'privileged_identity_execution_attempts', + 'privileged_identity_execution_attempt_events' + ) + """ + ), + {"schema_name": schema_name}, + ) + } + assert { + "trg_privileged_identity_execution_attempts_immutable", + "trg_privileged_identity_execution_attempt_events_immutable", + } <= execution_triggers yield PostgresGovernanceHarness( schema_name=schema_name, session_factory=session_factory, @@ -311,6 +432,35 @@ def _submission_request( ) +def _account_merge_submission_request( + repository: PrivilegedIdentityOperationRepository, + operation_id: str, + *, + source_account_id: str, + target_account_id: str, +) -> PrivilegedIdentityOperationSubmitRequest: + preview = repository.preview( + PrivilegedIdentityOperationPreviewRequest( + operation_type="account_merge", + target_refs={ + "source_account_id": source_account_id, + "target_account_id": target_account_id, + }, + evidence_refs=[], + ) + ) + return PrivilegedIdentityOperationSubmitRequest( + operation_id=operation_id, + operation_type=preview.operation_type, + target_refs=preview.target_refs, + evidence_refs=preview.evidence_refs, + preview_hash=preview.preview_hash, + preview_expires_at=preview.expires_at, + reason="PostgreSQL fixed lock order verification", + ticket_reference="PG-LOCK-ORDER-20260803", + ) + + @contextmanager def _trace(trace_id: str) -> Iterator[None]: set_trace_id(trace_id) @@ -393,6 +543,27 @@ def _assert_event_integrity(events: list[PrivilegedIdentityOperationEventModel]) assert "postgres-concurrency.invalid" not in serialized +def _assert_execution_attempt_event_integrity( + events: list[PrivilegedIdentityExecutionAttemptEventModel], +) -> None: + assert [event.sequence_no for event in events] == list(range(1, len(events) + 1)) + assert len({event.sequence_no for event in events}) == len(events) + for event in events: + assert event.snapshot_before_hash == privileged_payload_hash(event.snapshot_before) + assert event.snapshot_after_hash == privileged_payload_hash(event.snapshot_after) + assert event.event_payload_hash == privileged_payload_hash( + { + "attempt_id": event.attempt_id, + "event_type": event.event_type, + "status_before": event.status_before, + "status_after": event.status_after, + "error_code": event.error_code, + "snapshot_before_hash": event.snapshot_before_hash, + "snapshot_after_hash": event.snapshot_after_hash, + } + ) + + def _governance_storage_snapshot( session_factory: sessionmaker[Session], ) -> tuple[tuple[tuple[object, ...], ...], ...]: @@ -430,7 +601,64 @@ def _governance_storage_snapshot( ) ).all() ) - return operations, approvals, events, outbox + execution_attempts = tuple( + tuple(row) + for row in session.execute( + select(*PrivilegedIdentityExecutionAttemptModel.__table__.c).order_by( + PrivilegedIdentityExecutionAttemptModel.attempt_id + ) + ).all() + ) + execution_attempt_events = tuple( + tuple(row) + for row in session.execute( + select(*PrivilegedIdentityExecutionAttemptEventModel.__table__.c).order_by( + PrivilegedIdentityExecutionAttemptEventModel.attempt_id, + PrivilegedIdentityExecutionAttemptEventModel.sequence_no, + ) + ).all() + ) + return ( + operations, + approvals, + events, + outbox, + execution_attempts, + execution_attempt_events, + ) + + +def _identity_storage_snapshot( + session_factory: sessionmaker[Session], +) -> tuple[tuple[tuple[object, ...], ...], ...]: + with session_factory() as session: + accounts = tuple( + tuple(row) + for row in session.execute( + select(*GlobalAccountModel.__table__.c).order_by(GlobalAccountModel.id) + ).all() + ) + workspaces = tuple( + tuple(row) + for row in session.execute( + select(*TenantModel.__table__.c).order_by(TenantModel.id) + ).all() + ) + users = tuple( + tuple(row) + for row in session.execute( + select(*TenantUserModel.__table__.c).order_by(TenantUserModel.id) + ).all() + ) + memberships = tuple( + tuple(row) + for row in session.execute( + select(*WorkspaceMembershipModel.__table__.c).order_by( + WorkspaceMembershipModel.id + ) + ).all() + ) + return accounts, workspaces, users, memberships def test_postgresql_privileged_governance_concurrency( @@ -540,6 +768,559 @@ def test_postgresql_privileged_governance_concurrency( assert {event.trace_id for event in distinct_events[1:]} == set(distinct_traces) _assert_event_integrity(distinct_events) + governance_before_ready_preflight = _governance_storage_snapshot(session_factory) + identity_before_ready_preflight = _identity_storage_snapshot(session_factory) + ready_preflight = repository.get_execution_preflight( + distinct_request.operation_id, + actor_tenant_id=_TENANT_ID, + actor_user_id="user-requester", + ) + assert ready_preflight.ready is True + assert ready_preflight.blockers == [] + assert ready_preflight.valid_approval_count == 2 + assert ready_preflight.preview_hash_verified is True + assert ready_preflight.locks_acquired is False + assert ready_preflight.executor_enabled is False + assert [item.sequence for item in ready_preflight.lock_plan] == list( + range(1, len(ready_preflight.lock_plan) + 1) + ) + assert [item.resource_type for item in ready_preflight.lock_plan[:2]] == [ + "privileged_operation", + "privileged_approval_set", + ] + assert governance_before_ready_preflight == _governance_storage_snapshot(session_factory) + assert identity_before_ready_preflight == _identity_storage_snapshot(session_factory) + + locked_statements: list[str] = [] + bind = session_factory.kw["bind"] + + def capture_locked_statement( + _connection, + _cursor, + statement: str, + _parameters, + _context, + _executemany, + ) -> None: + if "FOR UPDATE" in statement.upper(): + locked_statements.append(statement.lower()) + + sqlalchemy_event.listen(bind, "before_cursor_execute", capture_locked_statement) + try: + locked_before = _governance_storage_snapshot(session_factory) + locked_identity_before = _identity_storage_snapshot(session_factory) + locked_ready = repository.recheck_execution_preflight_under_lock( + distinct_request.operation_id, + actor_tenant_id=_TENANT_ID, + actor_user_id="user-requester", + lock_timeout_ms=2_000, + ) + locked_after = _governance_storage_snapshot(session_factory) + locked_identity_after = _identity_storage_snapshot(session_factory) + finally: + sqlalchemy_event.remove(bind, "before_cursor_execute", capture_locked_statement) + assert locked_ready.ready is True + assert locked_ready.locks_acquired is True + assert locked_ready.executor_enabled is False + assert locked_before == locked_after + assert locked_identity_before == locked_identity_after + lock_table_by_resource = { + "privileged_operation": "privileged_identity_operations", + "privileged_approval_set": "privileged_identity_operation_approvals", + "identity_account": "identity_accounts", + "workspace": "tenants", + "tenant_user": "tenant_users", + "workspace_membership": "workspace_memberships", + "identity_account_credential": "identity_account_credentials", + "tenant_user_credential": "tenant_user_credentials", + } + expected_lock_tables = [ + lock_table_by_resource[item.resource_type] + for item in locked_ready.lock_plan + if item.resource_type in lock_table_by_resource + ] + expected_lock_tables = list(dict.fromkeys(expected_lock_tables)) + acquired_lock_tables: list[str] = [] + for statement in locked_statements: + matching_tables = [ + table_name + for table_name in lock_table_by_resource.values() + if f"from {table_name}" in statement + ] + assert len(matching_tables) == 1, statement + acquired_lock_tables.append(matching_tables[0]) + lock_rank_by_table = { + table_name: rank + for rank, table_name in enumerate(lock_table_by_resource.values(), start=1) + } + assert acquired_lock_tables == list(dict.fromkeys(acquired_lock_tables)) + assert [lock_rank_by_table[item] for item in acquired_lock_tables] == sorted( + lock_rank_by_table[item] for item in acquired_lock_tables + ) + assert all(item in acquired_lock_tables for item in expected_lock_tables) + assert [ + item for item in acquired_lock_tables if item in expected_lock_tables + ] == expected_lock_tables + + original_target_revision: datetime + with session_factory.begin() as session: + target = session.get(TenantUserModel, "user-target") + assert target is not None + original_target_revision = target.updated_at + target.updated_at = original_target_revision + timedelta(microseconds=1) + drift_before = _governance_storage_snapshot(session_factory) + drift_identity_before = _identity_storage_snapshot(session_factory) + drifted_locked = repository.recheck_execution_preflight_under_lock( + distinct_request.operation_id, + actor_tenant_id=_TENANT_ID, + actor_user_id="user-requester", + ) + assert drifted_locked.ready is False + assert drifted_locked.locks_acquired is True + assert "revision_mismatch" in {item.code for item in drifted_locked.blockers} + assert drift_before == _governance_storage_snapshot(session_factory) + assert drift_identity_before == _identity_storage_snapshot(session_factory) + with session_factory.begin() as session: + target = session.get(TenantUserModel, "user-target") + assert target is not None + target.updated_at = original_target_revision + + original_approval_expiry: datetime + with session_factory.begin() as session: + approval = session.execute( + select(PrivilegedIdentityOperationApprovalModel).where( + PrivilegedIdentityOperationApprovalModel.operation_id + == distinct_request.operation_id, + PrivilegedIdentityOperationApprovalModel.approver_user_id + == "user-approver-one", + ) + ).scalar_one() + original_approval_expiry = approval.expires_at + approval.expires_at = datetime.now(UTC) - timedelta(seconds=1) + approval_before = _governance_storage_snapshot(session_factory) + approval_identity_before = _identity_storage_snapshot(session_factory) + approval_invalid = repository.recheck_execution_preflight_under_lock( + distinct_request.operation_id, + actor_tenant_id=_TENANT_ID, + actor_user_id="user-requester", + ) + assert approval_invalid.ready is False + assert approval_invalid.locks_acquired is True + assert {"approval_expired", "approval_quorum_missing"} <= { + item.code for item in approval_invalid.blockers + } + assert approval_before == _governance_storage_snapshot(session_factory) + assert approval_identity_before == _identity_storage_snapshot(session_factory) + with session_factory.begin() as session: + approval = session.execute( + select(PrivilegedIdentityOperationApprovalModel).where( + PrivilegedIdentityOperationApprovalModel.operation_id + == distinct_request.operation_id, + PrivilegedIdentityOperationApprovalModel.approver_user_id + == "user-approver-one", + ) + ).scalar_one() + approval.expires_at = original_approval_expiry + + with session_factory.begin() as session: + approver = session.get(TenantUserModel, "user-approver-two") + assert approver is not None + original_permissions = list(approver.platform_permissions or []) + approver.platform_permissions = [] + permission_before = _governance_storage_snapshot(session_factory) + permission_identity_before = _identity_storage_snapshot(session_factory) + permission_invalid = repository.recheck_execution_preflight_under_lock( + distinct_request.operation_id, + actor_tenant_id=_TENANT_ID, + actor_user_id="user-requester", + ) + assert permission_invalid.ready is False + assert permission_invalid.locks_acquired is True + assert {"approver_not_current", "approval_quorum_missing"} <= { + item.code for item in permission_invalid.blockers + } + assert permission_before == _governance_storage_snapshot(session_factory) + assert permission_identity_before == _identity_storage_snapshot(session_factory) + with session_factory.begin() as session: + approver = session.get(TenantUserModel, "user-approver-two") + assert approver is not None + approver.platform_permissions = original_permissions + + reverse_merge_requests = ( + _account_merge_submission_request( + repository, + "pg-lock-order-forward", + source_account_id="account-requester", + target_account_id="account-target", + ), + _account_merge_submission_request( + repository, + "pg-lock-order-reverse", + source_account_id="account-target", + target_account_id="account-requester", + ), + ) + for request in reverse_merge_requests: + operation = _submit(repository, request).item + _approve( + repository, + request.operation_id, + operation.preview_hash, + "user-approver-one", + ) + approved = _approve( + repository, + request.operation_id, + operation.preview_hash, + "user-approver-two", + ) + assert approved.status == "approved" + with session_factory.begin() as session: + reverse_operation = session.get( + PrivilegedIdentityOperationModel, + "pg-lock-order-reverse", + ) + assert reverse_operation is not None + reverse_operation.expected_revisions = list( + reversed(reverse_operation.expected_revisions) + ) + reverse_before = _governance_storage_snapshot(session_factory) + reverse_identity_before = _identity_storage_snapshot(session_factory) + reverse_operation_ids = { + "trace-lock-forward": "pg-lock-order-forward", + "trace-lock-reverse": "pg-lock-order-reverse", + } + reverse_lock_results = _concurrent( + list(reverse_operation_ids), + lambda trace_id: repository.recheck_execution_preflight_under_lock( + reverse_operation_ids[trace_id], + actor_tenant_id=_TENANT_ID, + actor_user_id="user-requester", + lock_timeout_ms=5_000, + ), + ) + assert all(item.ready for item in reverse_lock_results) + assert all(item.locks_acquired for item in reverse_lock_results) + assert reverse_before == _governance_storage_snapshot(session_factory) + assert reverse_identity_before == _identity_storage_snapshot(session_factory) + + timeout_before = _governance_storage_snapshot(session_factory) + timeout_identity_before = _identity_storage_snapshot(session_factory) + blocker_session = session_factory() + blocker_transaction = blocker_session.begin() + try: + blocker_session.execute( + select(GlobalAccountModel) + .where(GlobalAccountModel.id == "account-target") + .with_for_update() + ).scalar_one() + with pytest.raises(PrivilegedIdentityOperationError) as timeout_error: + repository.recheck_execution_preflight_under_lock( + "pg-lock-order-forward", + actor_tenant_id=_TENANT_ID, + actor_user_id="user-requester", + lock_timeout_ms=50, + ) + assert timeout_error.value.status_code == 423 + assert timeout_error.value.code == "execution_lock_timeout" + with session_factory.begin() as probe_session: + probe_session.execute( + select(PrivilegedIdentityOperationModel) + .where( + PrivilegedIdentityOperationModel.operation_id + == "pg-lock-order-forward" + ) + .with_for_update(nowait=True) + ).scalar_one() + probe_session.execute( + select(GlobalAccountModel) + .where(GlobalAccountModel.id == "account-requester") + .with_for_update(nowait=True) + ).scalar_one() + finally: + blocker_transaction.rollback() + blocker_session.close() + assert timeout_before == _governance_storage_snapshot(session_factory) + assert timeout_identity_before == _identity_storage_snapshot(session_factory) + + with session_factory.begin() as session: + target = session.get(TenantUserModel, "user-target") + assert target is not None + target.updated_at = target.updated_at + timedelta(microseconds=1) + governance_before_stale_preflight = _governance_storage_snapshot(session_factory) + identity_before_stale_preflight = _identity_storage_snapshot(session_factory) + stale_preflight = repository.get_execution_preflight( + distinct_request.operation_id, + actor_tenant_id=_TENANT_ID, + actor_user_id="user-requester", + ) + assert stale_preflight.ready is False + assert "revision_mismatch" in {item.code for item in stale_preflight.blockers} + assert any(not item.matches for item in stale_preflight.revisions) + assert stale_preflight.locks_acquired is False + assert stale_preflight.executor_enabled is False + assert governance_before_stale_preflight == _governance_storage_snapshot(session_factory) + assert identity_before_stale_preflight == _identity_storage_snapshot(session_factory) + + with session_factory.begin() as session: + pending_same = session.get( + PrivilegedIdentityOperationModel, + same_request.operation_id, + ) + assert pending_same is not None + pending_same.expires_at = datetime.now(UTC) - timedelta(seconds=30) + sla_due_request = _submission_request(repository, "pg-sla-due-soon") + with _trace("trace-sla-due-submit"): + _submit(repository, sla_due_request) + sla_on_track_request = _submission_request(repository, "pg-sla-on-track") + with _trace("trace-sla-on-track-submit"): + _submit(repository, sla_on_track_request) + sla_outside_request = _submission_request(repository, "pg-sla-outside-window") + with _trace("trace-sla-outside-submit"): + _submit(repository, sla_outside_request) + with session_factory.begin() as session: + due_operation = session.get( + PrivilegedIdentityOperationModel, + sla_due_request.operation_id, + ) + on_track_operation = session.get( + PrivilegedIdentityOperationModel, + sla_on_track_request.operation_id, + ) + outside_operation = session.get( + PrivilegedIdentityOperationModel, + sla_outside_request.operation_id, + ) + assert due_operation is not None + assert on_track_operation is not None + assert outside_operation is not None + now = datetime.now(UTC) + due_operation.expires_at = now + timedelta(seconds=30) + on_track_operation.expires_at = now + timedelta(hours=2) + outside_operation.created_at = now - timedelta(days=8) + outside_operation.expires_at = now - timedelta(days=7) + + sla_before = _governance_storage_snapshot(session_factory) + sla_identity_before = _identity_storage_snapshot(session_factory) + sla_overview = repository.get_approval_sla_overview( + actor_tenant_id=_TENANT_ID, + actor_user_id="user-requester", + due_soon_seconds=60, + window_days=7, + ) + sla_queue = repository.list_approval_sla_queue( + actor_tenant_id=_TENANT_ID, + actor_user_id="user-requester", + due_soon_seconds=60, + window_days=7, + page=1, + page_size=100, + ) + sla_all_queue = repository.list_approval_sla_queue( + actor_tenant_id=_TENANT_ID, + actor_user_id="user-requester", + due_soon_seconds=60, + window_days=7, + page=1, + page_size=100, + scope="all", + ) + sla_after = _governance_storage_snapshot(session_factory) + sla_identity_after = _identity_storage_snapshot(session_factory) + sla_items = {item.operation_id: item for item in sla_queue.items} + assert sla_items[same_request.operation_id].sla_status == "breached" + assert sla_items[sla_due_request.operation_id].sla_status == "due_soon" + assert sla_items[sla_on_track_request.operation_id].sla_status == "on_track" + assert sla_outside_request.operation_id not in sla_items + assert { + item.operation_id for item in sla_all_queue.items + } >= {sla_outside_request.operation_id} + assert sla_overview.counts.breached >= 1 + assert sla_overview.counts.due_soon >= 1 + assert sla_overview.counts.on_track >= 1 + assert sla_overview.all_pending_total == sla_overview.total + 1 + assert sla_overview.outside_window_pending_count == 1 + assert sla_overview.outside_window_breached_count == 1 + assert sla_overview.oldest_pending_age_seconds is not None + assert sla_overview.oldest_pending_age_seconds >= 8 * 24 * 60 * 60 + assert sla_queue.scope == "window" + assert sla_all_queue.scope == "all" + assert sla_before == sla_after + assert sla_identity_before == sla_identity_after + serialized_sla = json.dumps( + { + "overview": sla_overview.model_dump(mode="json"), + "queue": sla_queue.model_dump(mode="json"), + "all_queue": sla_all_queue.model_dump(mode="json"), + }, + sort_keys=True, + ) + for forbidden_value in ( + "target_refs", + "evidence_refs", + "reason", + "ticket_reference", + "actor_account_id", + "approvals", + "comment", + "postgres-concurrency.invalid", + ): + assert forbidden_value not in serialized_sla + + execution_attempt_idempotency_key = "pg-execution-attempt-idempotent-001" + execution_attempt_results = _concurrent( + ["trace-attempt-register-one", "trace-attempt-register-two"], + lambda _trace_id: repository.register_execution_attempt( + distinct_request.operation_id, + idempotency_key=execution_attempt_idempotency_key, + request_hash="a" * 64, + preview_hash=distinct_request.preview_hash, + preflight_hash="b" * 64, + ), + ) + assert len({item.attempt_id for item in execution_attempt_results}) == 1 + assert {item.attempt_no for item in execution_attempt_results} == {1} + attempt_id = execution_attempt_results[0].attempt_id + with session_factory() as session: + attempts = session.execute( + select(PrivilegedIdentityExecutionAttemptModel).where( + PrivilegedIdentityExecutionAttemptModel.operation_id + == distinct_request.operation_id + ) + ).scalars().all() + attempt_events = session.execute( + select(PrivilegedIdentityExecutionAttemptEventModel).where( + PrivilegedIdentityExecutionAttemptEventModel.attempt_id == attempt_id + ) + ).scalars().all() + assert len(attempts) == 1 + assert len(attempt_events) == 1 + assert attempt_events[0].event_type == "attempt_prepared" + + append_results = _concurrent( + ["trace-attempt-event-one", "trace-attempt-event-two"], + lambda _trace_id: repository.append_execution_attempt_event( + attempt_id, + event_type="attempt_started", + status_before="prepared", + status_after="running", + snapshot_before={"status": "prepared", "attempt_no": 1}, + snapshot_after={ + "status": "running", + "attempt_no": 1, + "preflight_hash": "b" * 64, + }, + trace_id="trace-attempt-event-append", + ), + ) + assert {item.attempt_id for item in append_results} == {attempt_id} + with session_factory() as session: + attempt_events = session.execute( + select(PrivilegedIdentityExecutionAttemptEventModel) + .where(PrivilegedIdentityExecutionAttemptEventModel.attempt_id == attempt_id) + .order_by(PrivilegedIdentityExecutionAttemptEventModel.sequence_no) + ).scalars().all() + assert [event.sequence_no for event in attempt_events] == [1, 2] + assert [event.event_type for event in attempt_events] == [ + "attempt_prepared", + "attempt_started", + ] + _assert_execution_attempt_event_integrity(attempt_events) + + for table_name, id_column, row_id in ( + ("privileged_identity_execution_attempts", "attempt_id", attempt_id), + ( + "privileged_identity_execution_attempt_events", + "event_id", + attempt_events[0].event_id, + ), + ): + with pytest.raises(DBAPIError): + with session_factory.begin() as session: + session.execute( + text( + f"UPDATE {table_name} SET trace_id = 'tampered' " + f"WHERE {id_column} = :row_id" + ), + {"row_id": row_id}, + ) + with pytest.raises(DBAPIError): + with session_factory.begin() as session: + session.execute( + text(f"DELETE FROM {table_name} WHERE {id_column} = :row_id"), + {"row_id": row_id}, + ) + with pytest.raises(DBAPIError): + with session_factory.begin() as session: + session.execute( + text( + "INSERT INTO privileged_identity_execution_attempts " + "(attempt_id, operation_id, attempt_no, idempotency_key, " + "request_hash, preview_hash, preflight_hash, trace_id, created_at) " + "SELECT 'pg-attempt-duplicate-number', operation_id, attempt_no, " + "'pg-attempt-duplicate-number-key', request_hash, preview_hash, " + "preflight_hash, trace_id, created_at " + "FROM privileged_identity_execution_attempts " + "WHERE attempt_id = :attempt_id" + ), + {"attempt_id": attempt_id}, + ) + with pytest.raises(DBAPIError): + with session_factory.begin() as session: + session.execute( + text( + "INSERT INTO privileged_identity_execution_attempts " + "(attempt_id, operation_id, attempt_no, idempotency_key, " + "request_hash, preview_hash, preflight_hash, trace_id, created_at) " + "SELECT 'pg-attempt-duplicate-key', operation_id, 2, idempotency_key, " + "request_hash, preview_hash, preflight_hash, trace_id, created_at " + "FROM privileged_identity_execution_attempts " + "WHERE attempt_id = :attempt_id" + ), + {"attempt_id": attempt_id}, + ) + with pytest.raises(DBAPIError): + with session_factory.begin() as session: + session.execute( + text( + "INSERT INTO privileged_identity_execution_attempt_events " + "(event_id, attempt_id, sequence_no, event_type, status_before, " + "status_after, error_code, trace_id, snapshot_before, snapshot_after, " + "snapshot_before_hash, snapshot_after_hash, event_payload_hash, created_at) " + "SELECT 'pg-attempt-event-duplicate-sequence', attempt_id, sequence_no, " + "event_type, status_before, status_after, error_code, trace_id, " + "snapshot_before, snapshot_after, snapshot_before_hash, " + "snapshot_after_hash, '7' || substr(event_payload_hash, 2), created_at " + "FROM privileged_identity_execution_attempt_events " + "WHERE event_id = :event_id" + ), + {"event_id": attempt_events[1].event_id}, + ) + with pytest.raises(DBAPIError): + with session_factory.begin() as session: + session.execute( + text( + "INSERT INTO privileged_identity_execution_attempt_events " + "(event_id, attempt_id, sequence_no, event_type, status_before, " + "status_after, error_code, trace_id, snapshot_before, snapshot_after, " + "snapshot_before_hash, snapshot_after_hash, event_payload_hash, created_at) " + "SELECT 'pg-attempt-event-duplicate-payload', attempt_id, 3, " + "event_type, status_before, status_after, error_code, trace_id, " + "snapshot_before, snapshot_after, snapshot_before_hash, " + "snapshot_after_hash, event_payload_hash, created_at " + "FROM privileged_identity_execution_attempt_events " + "WHERE event_id = :event_id" + ), + {"event_id": attempt_events[1].event_id}, + ) + + print( + "Execution attempt 0043 evidence: " + "concurrent_registration_idempotent=1; " + "concurrent_event_append_once=1; immutable_update_delete=verified; " + "attempt_event_uniqueness=verified" + ) + rejection_request = _submission_request(repository, "pg-rejection") with _trace("trace-rejection-submit"): rejection_operation = _submit(repository, rejection_request).item @@ -650,7 +1431,24 @@ def test_postgresql_privileged_governance_concurrency( for message in batch } leased_message = claimed[0] - fake_sink_deliveries = [leased_message.idempotency_key] + delivery_contract = PrivilegedIdentityDeliveryContract( + key_id="postgres-contract-test-key", + signing_secret=bytes([67]) * 32, + delivery_key_secret=bytes([71]) * 32, + ) + first_envelope = delivery_contract.build(leased_message) + fake_sink_deliveries = [first_envelope.delivery_key] + first_document = first_envelope.canonical_document().decode("utf-8").lower() + for forbidden_value in ( + leased_message.message_id.lower(), + leased_message.event_id.lower(), + leased_message.operation_id.lower(), + leased_message.idempotency_key.lower(), + "trace_id", + "target_reference", + "snapshot", + ): + assert forbidden_value not in first_document for message in claimed[1:]: assert outbox.mark_delivered( message.message_id, @@ -672,8 +1470,10 @@ def test_postgresql_privileged_governance_concurrency( assert recovered[0].message_id == leased_message.message_id assert recovered[0].idempotency_key == leased_message.idempotency_key assert recovered[0].attempt_count == leased_message.attempt_count + 1 - fake_sink_deliveries.append(recovered[0].idempotency_key) - assert fake_sink_deliveries == [leased_message.idempotency_key] * 2 + recovered_envelope = delivery_contract.build(recovered[0]) + fake_sink_deliveries.append(recovered_envelope.delivery_key) + assert recovered_envelope == first_envelope + assert fake_sink_deliveries == [first_envelope.delivery_key] * 2 assert outbox.mark_failed( recovered[0].message_id, worker_id="trace-outbox-recovery", @@ -803,7 +1603,193 @@ def test_postgresql_privileged_governance_concurrency( "distinct_approvers=2 approvals/ordered status approval; " "rejection=terminal/unique; expiry=materialized_once; " "trace_and_snapshot_hashes=verified; outbox_skip_locked=unique; " - "outbox_lease_retry_dead_letter=verified; " + "outbox_lease_retry_dead_letter=verified; egress_contract_stable=verified; " "outbox_observability_read_only=verified; outbox_0041_integrity=verified; " + "execution_preflight_0042_read_only=verified; approval_sla_read_only=verified; " + "execution_attempt_0043_append_only=verified; " + "fixed_lock_order_and_timeout=verified; locked_preflight_fail_closed=verified; " "final_admin=protected" ) + + +def test_postgresql_identity_rehearsal_faults_roll_back_outbox_atomically( + postgres_governance: PostgresGovernanceHarness, +) -> None: + """Exercise the future plan boundary without enabling an identity executor. + + The rehearsal uses the real PostgreSQL transaction and the existing event / + outbox tables in the isolated schema. Both injected failures must roll + back the identity projection, event and outbox together; the successful + probe verifies that the event and its outbox message commit as one unit. + """ + + repository = postgres_governance.repository + session_factory = postgres_governance.session_factory + requester_id = "user-requester" + target_user_id = "user-rehearsal-target" + approver_one_id = "user-rehearsal-approver-one" + approver_two_id = "user-rehearsal-approver-two" + with session_factory.begin() as session: + tenant = session.get(TenantModel, _TENANT_ID) + if tenant is None: + now = datetime.now(UTC) + session.add( + TenantModel( + id=_TENANT_ID, + name="PostgreSQL rehearsal workspace", + workspace_type="system", + owner_user_id="user-requester", + legal_name=None, + status="active", + plan=None, + region=None, + settings={}, + created_by="postgres-rehearsal-test", + updated_by="postgres-rehearsal-test", + deleted_at=None, + created_at=now, + updated_at=now, + ) + ) + existing_users = { + user_id + for user_id in (requester_id, target_user_id, approver_one_id, approver_two_id) + if session.get(TenantUserModel, user_id) is not None + } + now = datetime.now(UTC) + for account_id, user_id, is_admin in ( + ("account-requester", requester_id, True), + ("account-rehearsal-target", target_user_id, False), + ("account-rehearsal-approver-one", approver_one_id, True), + ("account-rehearsal-approver-two", approver_two_id, True), + ): + if user_id not in existing_users: + _seed_identity( + session, + account_id=account_id, + user_id=user_id, + platform_admin=is_admin, + now=now, + ) + + operation_id = "operation-pg-rehearsal-atomic-001" + preview = repository.preview( + PrivilegedIdentityOperationPreviewRequest( + operation_type="platform_admin_grant", + target_refs={ + "target_tenant_id": _TENANT_ID, + "target_user_id": target_user_id, + }, + evidence_refs=[], + ) + ) + operation = _submit( + repository, + PrivilegedIdentityOperationSubmitRequest( + operation_id=operation_id, + operation_type=preview.operation_type, + target_refs=preview.target_refs, + evidence_refs=preview.evidence_refs, + preview_hash=preview.preview_hash, + preview_expires_at=preview.expires_at, + reason="PostgreSQL identity transaction rehearsal", + ticket_reference="PG-REHEARSAL-20260809", + ), + ).item + for approver in (approver_one_id, approver_two_id): + _approve(repository, operation_id, operation.preview_hash, approver) + + plan = repository.build_internal_change_plan( + operation_id, + actor_tenant_id=_TENANT_ID, + actor_user_id=requester_id, + ) + assert plan.executor_enabled is False + assert plan.plan_hash and plan.compensation_plan_hash + + before_governance = _governance_storage_snapshot(session_factory) + before_identity = _identity_storage_snapshot(session_factory) + + with pytest.raises(RuntimeError, match="after-identity-update"): + with session_factory.begin() as session: + target = session.get(TenantUserModel, target_user_id) + assert target is not None + target.platform_permissions = ["platform_admin"] + target.updated_at = datetime.now(UTC) + session.flush() + raise RuntimeError("after-identity-update") + + assert before_governance == _governance_storage_snapshot(session_factory) + assert before_identity == _identity_storage_snapshot(session_factory) + + with session_factory.begin() as session: + target = session.get(TenantUserModel, target_user_id) + stored_operation = session.get(PrivilegedIdentityOperationModel, operation_id) + assert target is not None and stored_operation is not None + target.platform_permissions = ["platform_admin"] + target.updated_at = datetime.now(UTC) + snapshot = repository._event_snapshot( + stored_operation, + status="approved", + approval_count=2, + ) + repository._append_event( + session, + stored_operation, + event_type="approval_recorded", + status_before="approved", + status_after="approved", + actor_account_id="account-rehearsal-approver-one", + actor_tenant_id=_TENANT_ID, + actor_user_id=approver_one_id, + snapshot_before=snapshot, + snapshot_after=snapshot, + now=datetime.now(UTC), + ) + + committed_governance = _governance_storage_snapshot(session_factory) + committed_identity = _identity_storage_snapshot(session_factory) + assert committed_governance != before_governance + assert committed_identity != before_identity + with session_factory() as session: + event_count = session.scalar( + select(func.count(PrivilegedIdentityOperationEventModel.event_id)).where( + PrivilegedIdentityOperationEventModel.operation_id == operation_id + ) + ) + outbox_count = session.scalar( + select(func.count(PrivilegedIdentityOutboxMessageModel.message_id)).where( + PrivilegedIdentityOutboxMessageModel.operation_id == operation_id + ) + ) + assert event_count == outbox_count == 4 + + with pytest.raises(RuntimeError, match="after-outbox"): + with session_factory.begin() as session: + target = session.get(TenantUserModel, target_user_id) + stored_operation = session.get(PrivilegedIdentityOperationModel, operation_id) + assert target is not None and stored_operation is not None + target.platform_permissions = [] + target.updated_at = datetime.now(UTC) + snapshot = repository._event_snapshot( + stored_operation, + status="approved", + approval_count=2, + ) + repository._append_event( + session, + stored_operation, + event_type="approval_recorded", + status_before="approved", + status_after="approved", + actor_account_id="account-rehearsal-approver-two", + actor_tenant_id=_TENANT_ID, + actor_user_id=approver_two_id, + snapshot_before=snapshot, + snapshot_after=snapshot, + now=datetime.now(UTC), + ) + raise RuntimeError("after-outbox") + + assert committed_governance == _governance_storage_snapshot(session_factory) + assert committed_identity == _identity_storage_snapshot(session_factory) diff --git a/yuqei-contract-platform/services/contract-api/tests/test_privileged_support_access.py b/yuqei-contract-platform/services/contract-api/tests/test_privileged_support_access.py new file mode 100644 index 00000000..52477add --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/tests/test_privileged_support_access.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest + +from yuqei_contract_api.privileged_identity import PrivilegedIdentityExpectedRevision +from yuqei_contract_api.privileged_support_access import ( + PrivilegedSupportAccessError, + build_privileged_support_access_grant, + check_privileged_support_access, + revoke_privileged_support_access, + validate_privileged_support_access_grant, +) + + +NOW = datetime(2026, 8, 9, 12, 0, tzinfo=UTC) + + +def _grant(): + return build_privileged_support_access_grant( + operation_id="operation-support-validation-001", + target_refs={ + "support_account_id": "account-support-001", + "tenant_id": "workspace-001", + "permission_scopes": ["review.read", "contract.read", "contract.read"], + "expires_at": (NOW + timedelta(hours=2)).isoformat(), + "subject_reference": "SUP-2026-0809-001", + }, + expected_revisions=[ + PrivilegedIdentityExpectedRevision( + entity_type="workspace", + entity_id="workspace-001", + revision="workspace-revision-1", + ), + PrivilegedIdentityExpectedRevision( + entity_type="identity_account", + entity_id="account-support-001", + revision="account-revision-1", + ), + ], + operation_status="approved", + valid_approval_count=2, + preview_hash_verified=True, + issued_at=NOW, + ) + + +def _check(grant, **overrides): + values = { + "account_id": "account-support-001", + "tenant_id": "workspace-001", + "requested_scope": "contract.read", + "account_status": "active", + "workspace_status": "active", + "current_account_revision": "account-revision-1", + "current_workspace_revision": "workspace-revision-1", + "checked_at": NOW + timedelta(minutes=1), + } + values.update(overrides) + return check_privileged_support_access(grant, **values) + + +def test_valid_policy_is_deterministic_but_access_remains_disabled() -> None: + first = _grant() + second = _grant() + assert first == second + assert first.permission_scopes == ("contract.read", "review.read") + assert validate_privileged_support_access_grant(first) + result = _check(first) + assert result.policy_satisfied is True + assert result.access_granted is False + assert result.effective_status == "validation_only" + assert result.code == "support_access_executor_disabled" + serialized = first.model_dump_json() + assert "SUP-2026-0809-001" not in serialized + assert "password" not in serialized.lower() + assert "session" not in serialized.lower() + + +@pytest.mark.parametrize( + ("overrides", "code"), + [ + ({"account_id": "other-account"}, "support_access_subject_mismatch"), + ({"tenant_id": "other-workspace"}, "support_access_subject_mismatch"), + ({"requested_scope": "audit.read"}, "support_access_scope_denied"), + ({"account_status": "suspended"}, "support_access_principal_inactive"), + ({"workspace_status": "suspended"}, "support_access_principal_inactive"), + ({"current_account_revision": "changed"}, "support_access_revision_mismatch"), + ({"current_workspace_revision": "changed"}, "support_access_revision_mismatch"), + ], +) +def test_online_check_fails_closed(overrides: dict[str, object], code: str) -> None: + result = _check(_grant(), **overrides) + assert result.policy_satisfied is False + assert result.access_granted is False + assert result.code == code + + +def test_automatic_expiry_is_enforced_at_the_boundary() -> None: + before = _check(_grant(), checked_at=NOW + timedelta(hours=1, minutes=59)) + at_expiry = _check(_grant(), checked_at=NOW + timedelta(hours=2)) + after = _check(_grant(), checked_at=NOW + timedelta(days=1)) + assert before.policy_satisfied is True + assert at_expiry.effective_status == "expired" + assert after.effective_status == "expired" + assert at_expiry.seconds_until_expiry == after.seconds_until_expiry == 0 + + +def test_emergency_revocation_is_immediate_idempotent_and_immutable() -> None: + grant = _grant() + revoked_at = NOW + timedelta(minutes=10) + revoked = revoke_privileged_support_access( + grant, + revoked_at=revoked_at, + revocation_code="security_incident", + ) + assert validate_privileged_support_access_grant(revoked) + assert revoke_privileged_support_access( + revoked, + revoked_at=revoked_at, + revocation_code="security_incident", + ) == revoked + result = _check(revoked, checked_at=revoked_at) + assert result.effective_status == "revoked" + assert result.access_granted is False + with pytest.raises(PrivilegedSupportAccessError) as error: + revoke_privileged_support_access( + revoked, + revoked_at=revoked_at + timedelta(seconds=1), + revocation_code="operator_request", + ) + assert error.value.code == "support_access_already_revoked" + + +def test_policy_and_revocation_tampering_fail_closed() -> None: + grant = _grant() + tampered_policy = grant.model_copy(update={"account_revision": "tampered"}) + assert not validate_privileged_support_access_grant(tampered_policy) + assert _check(tampered_policy).code == "support_access_policy_invalid" + with pytest.raises(PrivilegedSupportAccessError) as resign_error: + revoke_privileged_support_access( + tampered_policy, + revoked_at=NOW + timedelta(minutes=5), + revocation_code="operator_request", + ) + assert resign_error.value.code == "support_access_policy_invalid" + + naive_time = grant.model_copy(update={"expires_at": datetime(2026, 8, 9, 14, 0)}) + assert not validate_privileged_support_access_grant(naive_time) + assert _check(naive_time).code == "support_access_policy_invalid" + + revoked = revoke_privileged_support_access( + grant, + revoked_at=NOW + timedelta(minutes=5), + revocation_code="operator_request", + ) + tampered_revocation = revoked.model_copy(update={"revocation_code": "security_incident"}) + assert not validate_privileged_support_access_grant(tampered_revocation) + assert _check(tampered_revocation).code == "support_access_policy_invalid" + + +@pytest.mark.parametrize( + ("updates", "code"), + [ + ({"operation_status": "pending_approval"}, "support_access_operation_not_approved"), + ({"valid_approval_count": 1}, "support_access_approval_not_current"), + ({"preview_hash_verified": False}, "support_access_approval_not_current"), + ], +) +def test_build_requires_current_approval_material(updates: dict[str, object], code: str) -> None: + arguments = { + "operation_id": "operation-support-validation-002", + "target_refs": { + "support_account_id": "account-support-001", + "tenant_id": "workspace-001", + "permission_scopes": ["contract.read"], + "expires_at": (NOW + timedelta(hours=1)).isoformat(), + "subject_reference": "SUP-2", + }, + "expected_revisions": [ + {"entity_type": "identity_account", "entity_id": "account-support-001", "revision": "a1"}, + {"entity_type": "workspace", "entity_id": "workspace-001", "revision": "w1"}, + ], + "operation_status": "approved", + "valid_approval_count": 2, + "preview_hash_verified": True, + "issued_at": NOW, + } + arguments.update(updates) + with pytest.raises(PrivilegedSupportAccessError) as error: + build_privileged_support_access_grant(**arguments) + assert error.value.code == code + + +def test_invalid_scope_expiry_revision_and_revocation_fail_closed() -> None: + base = { + "operation_id": "operation-support-validation-003", + "target_refs": { + "support_account_id": "account-support-001", + "tenant_id": "workspace-001", + "permission_scopes": ["contract.write"], + "expires_at": (NOW + timedelta(hours=1)).isoformat(), + "subject_reference": "SUP-3", + }, + "expected_revisions": [ + {"entity_type": "identity_account", "entity_id": "account-support-001", "revision": "a1"}, + {"entity_type": "workspace", "entity_id": "workspace-001", "revision": "w1"}, + ], + "operation_status": "approved", + "valid_approval_count": 2, + "preview_hash_verified": True, + "issued_at": NOW, + } + with pytest.raises(PrivilegedSupportAccessError) as scope_error: + build_privileged_support_access_grant(**base) + assert scope_error.value.code == "support_access_scope_invalid" + + base["target_refs"] = { + **base["target_refs"], + "permission_scopes": ["contract.read"], + "expires_at": (NOW + timedelta(hours=25)).isoformat(), + } + with pytest.raises(PrivilegedSupportAccessError) as expiry_error: + build_privileged_support_access_grant(**base) + assert expiry_error.value.code == "support_access_expiry_invalid" + + base["target_refs"] = { + **base["target_refs"], + "expires_at": (NOW + timedelta(hours=1)).isoformat(), + } + base["expected_revisions"] = base["expected_revisions"][:1] + with pytest.raises(PrivilegedSupportAccessError) as revision_error: + build_privileged_support_access_grant(**base) + assert revision_error.value.code == "support_access_revision_missing" + + with pytest.raises(PrivilegedSupportAccessError) as revoke_error: + revoke_privileged_support_access( + _grant(), + revoked_at=NOW, + revocation_code="free-form reason", + ) + assert revoke_error.value.code == "support_access_revocation_invalid"