feat: add governed privileged identity operations
Build and Test Quality Gate / Contract Web typecheck and build (pull_request) Successful in 10m18s
Build and Test Quality Gate / Python tests and Contract API migration (pull_request) Failing after 16m24s
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Failing after 12m50s
Build and Test Quality Gate / Contract Web typecheck and build (pull_request) Successful in 10m18s
Build and Test Quality Gate / Python tests and Contract API migration (pull_request) Failing after 16m24s
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Failing after 12m50s
This commit is contained in:
@@ -0,0 +1,685 @@
|
||||
# AI 合同平台 V2 特权身份操作治理方案
|
||||
|
||||
> 日期:2026-08-02
|
||||
>
|
||||
> 状态:第一阶段治理底座已实现;身份数据执行、回滚与支持访问授权仍未开放
|
||||
>
|
||||
> 适用范围:账号合并、账号拆分、工作空间成员关系恢复、平台管理员授予/撤销、限时支持访问
|
||||
|
||||
## 1. 结论
|
||||
|
||||
当前身份模型已经能够表达全局登录账号、工作空间内用户和两者之间的成员关系,也已经提供只读账号冲突诊断与平台管理员名册,但尚不具备安全执行高风险身份写操作所需的治理闭环。
|
||||
|
||||
上述五类操作不得分别建设为可直接调用的写接口。它们必须进入统一的“特权身份操作”控制面:先预览影响,再提交带理由的请求,由两名不同且有效的平台管理员审批,在预览与目标版本仍有效时通过单事务执行,并保留执行前后快照、事件审计与受控回滚能力。任何未经审批即可合并、拆分、恢复、授予、撤销或代入访问的 API 都是 P0 阻断项。
|
||||
|
||||
本方案不把 `tenant` 继续解释为“公司”。在持久化层,`TenantModel` 和 `tenant_id` 保持现状,作为工作空间与数据隔离边界;在产品和治理语义中,统一使用 `workspace`,并通过 `workspace_type = system | personal | organization` 区分系统、个人和组织工作空间。
|
||||
|
||||
## 2. 当前证据与约束
|
||||
|
||||
### 2.1 已确认的数据模型
|
||||
|
||||
| 当前实体 | 已有关键字段/约束 | 在治理方案中的含义 |
|
||||
| --- | --- | --- |
|
||||
| `TenantModel` / `tenants` | `id`、`workspace_type`、`owner_user_id`、`status` | 持久化工作空间和数据隔离边界;不改名、不迁移业务数据归属 |
|
||||
| `TenantUserModel` / `tenant_users` | `tenant_id`、`email`、`role`、`status`、`platform_permissions` | 工作空间内用户投影;`platform_admin` 是显式平台权限 |
|
||||
| `GlobalAccountModel` / `identity_accounts` | 全局唯一邮箱、状态、验证时间 | 跨工作空间的全局登录身份 |
|
||||
| `GlobalAccountCredentialModel` / `identity_account_credentials` | 每个全局账号唯一凭据、凭据状态 | 全局登录凭据 |
|
||||
| `WorkspaceMembershipModel` / `workspace_memberships` | `(account_id, tenant_id)` 唯一,`tenant_user_id` 唯一 | 全局账号与工作空间内用户投影的连接关系 |
|
||||
| `TenantUserCredentialModel` / `tenant_user_credentials` | 工作空间用户旧凭据 | 兼容期遗留登录凭据,不能在合并/拆分中被遗漏 |
|
||||
|
||||
代码证据位于:
|
||||
|
||||
- `yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/models.py`
|
||||
- `yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/identity.py`
|
||||
- `yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/identity.py`
|
||||
- `yuqei-contract-platform/services/contract-api/tests/test_identity_account_conflicts.py`
|
||||
- `yuqei-contract-platform/services/contract-api/tests/test_proxy_auth.py`
|
||||
|
||||
### 2.2 已有能力
|
||||
|
||||
- `GET /api/v1/identity/account-conflicts` 及详情接口是只读冲突诊断入口。
|
||||
- `GET /api/v1/identity/platform-administrators` 是只读平台管理员名册。
|
||||
- 跨空间平台权限以显式 `platform_permissions` 中的 `platform_admin` 为判断依据。
|
||||
- 现有逻辑已保护“最后一名有效平台管理员”不能被停用。
|
||||
- 全局账号凭据与工作空间旧凭据并存,治理操作必须覆盖两套凭据的归属和状态。
|
||||
|
||||
### 2.3 尚缺能力
|
||||
|
||||
- 没有统一的高风险操作请求、审批、执行和回滚状态机。
|
||||
- 没有可复用的影响预览、预览哈希和目标版本校验。
|
||||
- 没有跨重试幂等的 `operation_id`。
|
||||
- 没有两名不同平台管理员批准的强制机制。
|
||||
- 没有覆盖所有受影响身份实体的事务锁顺序与并发冲突协议。
|
||||
- 没有限时支持访问的理由、范围、到期和自动撤权模型。
|
||||
- 只读诊断 ID 可作为证据,但不能直接作为写操作标识或写入授权。
|
||||
|
||||
## 3. 治理原则
|
||||
|
||||
1. **先预览,后提交**:没有成功预览及 `preview_hash`,不能提交操作。
|
||||
2. **一次意图,一个 ID**:客户端生成或服务端分配全局唯一 `operation_id`;相同 ID 的重试返回原请求/结果,不得重复执行。
|
||||
3. **请求与批准分离**:请求人不能批准自己的操作。
|
||||
4. **双人批准**:必须由两名不同、当前有效的平台管理员批准;执行时再次验证两人资格。
|
||||
5. **有因、有界、有期限**:理由和工单引用必填;支持访问必须限定工作空间、权限范围和 `expires_at`。
|
||||
6. **单事务、无部分成功**:身份关系、凭据、权限和审计结果在一个事务边界内提交;任一不变量失败则全部回滚。
|
||||
7. **版本匹配才执行**:执行时 `preview_hash`、目标实体版本和关键状态必须与预览一致。
|
||||
8. **可逆优先**:优先停用、墓碑和血缘记录,不物理删除身份实体。
|
||||
9. **全程留痕**:预览、提交、批准、拒绝、执行、失败、回滚和到期都写追加式事件;保留脱敏的前后快照。
|
||||
10. **最小披露**:列表和审批页面只展示决策所需信息;凭据哈希、令牌、完整敏感字段永不返回。
|
||||
11. **服务端是唯一可信边界**:前端隐藏按钮不是授权;每个状态转换与执行动作都由后端重新鉴权和校验。
|
||||
12. **禁止旁路**:不存在无需该审批流即可完成同类破坏性写入的内部、管理端或批处理接口。
|
||||
|
||||
## 4. 操作目录与业务不变量
|
||||
|
||||
### 4.1 账号合并 `account_merge`
|
||||
|
||||
用途:将确认属于同一自然人的两个全局账号收敛为一个主账号。
|
||||
|
||||
请求必须明确:
|
||||
|
||||
- `source_account_id` 与 `target_account_id`
|
||||
- 规范化邮箱、邮箱验证状态和人工核验依据
|
||||
- 每个工作空间成员关系的处理计划
|
||||
- 全局凭据和旧工作空间凭据的保留/停用计划
|
||||
- 冲突诊断 `diagnostic_id`(如有,仅作证据引用)
|
||||
|
||||
不变量:
|
||||
|
||||
- 源账号和目标账号必须不同,且执行前均存在。
|
||||
- 合并只改变全局身份和成员关系所有权,不改变任何工作空间业务数据的 `tenant_id`。
|
||||
- 个人工作空间与组织工作空间的合同、模板、审查、审批等业务数据绝不互相合并或迁移。
|
||||
- 同一目标账号不能在同一工作空间产生两条有效成员关系;预览必须给出确定的冲突处理方案。
|
||||
- 主凭据只能保留一套有效登录入口;被停用凭据保留审计元数据,不回传密码哈希。
|
||||
- 源账号采用停用/墓碑状态,并记录 `merged_into_account_id` 血缘,不物理删除。
|
||||
- 合并不能隐式获得 `platform_admin`;涉及平台权限时须拆成独立的管理员授予操作。
|
||||
|
||||
### 4.2 账号拆分 `account_split`
|
||||
|
||||
用途:纠正错误合并、共用账号或错误归属,将成员关系与凭据按明确映射拆分到新/既有全局账号。
|
||||
|
||||
请求必须明确:
|
||||
|
||||
- `source_account_id`
|
||||
- `destination_account`(既有账号 ID 或待创建账号的最小资料)
|
||||
- `membership_partition`:每个待迁移 `workspace_membership_id` 的目标账号
|
||||
- `credential_ownership`:全局/旧凭据的保留、停用、重置与验证计划
|
||||
- 邮箱所有权重新验证方式
|
||||
|
||||
不变量:
|
||||
|
||||
- 每条成员关系只能分配给一个全局账号,不允许复制有效关系。
|
||||
- 拆分后 `(account_id, tenant_id)` 和 `tenant_user_id` 唯一约束仍成立。
|
||||
- 工作空间内用户投影和业务数据 `tenant_id` 不变。
|
||||
- 新凭据必须经过独立验证或强制重置,不能复制密码哈希到另一账号。
|
||||
- 平台管理员权限不会随成员关系自动拆分或复制。
|
||||
- 原账号与目标账号之间必须保留 `split_from` / `split_to` 血缘和操作引用。
|
||||
|
||||
### 4.3 工作空间成员关系恢复 `membership_recovery`
|
||||
|
||||
用途:修复缺失、错误停用或错误指向的 `WorkspaceMembershipModel`,使已核验全局账号重新关联正确的工作空间用户投影。
|
||||
|
||||
请求必须明确:
|
||||
|
||||
- `account_id`、`tenant_id`、`tenant_user_id`
|
||||
- 恢复类型:`reactivate | relink | recreate`
|
||||
- 账号与工作空间用户属于同一人的核验依据
|
||||
- 是否需要停用冲突成员关系
|
||||
|
||||
不变量:
|
||||
|
||||
- 工作空间和用户投影必须存在,且 `tenant_user.tenant_id == tenant_id`。
|
||||
- 不得跨个人/组织工作空间迁移用户投影。
|
||||
- 恢复后唯一约束成立,角色、owner 状态不得因恢复而越权升级。
|
||||
- 恢复 owner 关系时必须同时核对 `tenants.owner_user_id`,预览展示差异。
|
||||
- 诊断 ID 只能证明发现过冲突;执行仍以当前实体 ID 和版本为准。
|
||||
|
||||
### 4.4 平台管理员授予/撤销
|
||||
|
||||
操作类型:`platform_admin_grant`、`platform_admin_revoke`。
|
||||
|
||||
不变量:
|
||||
|
||||
- 目标必须是当前有效且可核验的身份,不以固定用户 ID 判断资格。
|
||||
- 工作空间 `admin` 与平台 `platform_admin` 是两种权限,禁止相互推导。
|
||||
- 授予或撤销必须单独发起,不得夹带在账号合并、拆分或成员恢复中。
|
||||
- 执行撤销后至少保留一名有效平台管理员;检查必须在锁定管理员集合后、提交前完成。
|
||||
- 请求人、两名审批人和目标人不能通过同一账号别名绕过职责分离检查。
|
||||
- 权限变化后撤销或刷新目标账号的高权限会话,确保新权限边界及时生效。
|
||||
|
||||
### 4.5 限时支持访问 `support_access_grant`
|
||||
|
||||
用途:平台支持人员在用户明确请求或合规处置时,对指定工作空间获得最小、限时、可审计的支持权限。
|
||||
|
||||
必填范围:
|
||||
|
||||
- `support_account_id`
|
||||
- `tenant_id`
|
||||
- `permission_scopes`
|
||||
- `reason`
|
||||
- `ticket_reference`
|
||||
- `expires_at`
|
||||
- 可选 `subject_reference`(如指定合同,但不得在列表页暴露敏感正文)
|
||||
|
||||
不变量:
|
||||
|
||||
- `expires_at` 必须晚于批准时间,并受平台最大时长限制;建议默认 2 小时、硬上限 24 小时。
|
||||
- 支持访问不能授予 `platform_admin`、工作空间 owner、成员管理或身份治理写权限。
|
||||
- 每次请求只能覆盖一个工作空间;跨空间必须分别申请。
|
||||
- 访问检查按“基础身份权限 ∩ 已批准 scope ∩ 未过期时间”计算。
|
||||
- 到期自动失效并写审计事件;缓存和会话授权不得超过授权到期时间。
|
||||
- 延期视为新操作,必须重新预览和双人批准,不能原地修改 `expires_at`。
|
||||
- 支持人员的读取、下载、导出和变更行为须附带 `support_grant_id` 写入业务审计。
|
||||
|
||||
## 5. 统一状态机
|
||||
|
||||
```text
|
||||
draft
|
||||
-> previewed
|
||||
-> pending_approval
|
||||
-> rejected (终态)
|
||||
-> cancelled (终态)
|
||||
-> expired (终态)
|
||||
-> approved
|
||||
-> executing
|
||||
-> succeeded
|
||||
-> failed (异常终态,可按策略重新预览后新建操作)
|
||||
|
||||
succeeded
|
||||
-> rollback_pending
|
||||
-> rolled_back (终态)
|
||||
-> rollback_failed (异常终态,进入人工事故处置)
|
||||
```
|
||||
|
||||
状态转换规则:
|
||||
|
||||
| 当前状态 | 动作 | 下一状态 | 强制条件 |
|
||||
| --- | --- | --- | --- |
|
||||
| `draft` | 生成预览 | `previewed` | 调用人有请求权限;目标存在;影响可计算 |
|
||||
| `previewed` | 提交审批 | `pending_approval` | 请求体与预览哈希一致;理由、工单、到期完整 |
|
||||
| `pending_approval` | 批准 | 保持/`approved` | 请求人不可批准;两名不同有效平台管理员后进入 `approved` |
|
||||
| `pending_approval` | 拒绝 | `rejected` | 拒绝理由必填 |
|
||||
| `previewed` / `pending_approval` | 取消 | `cancelled` | 仅请求人或有治理权限的平台管理员;写明理由 |
|
||||
| `pending_approval` / `approved` | 超时 | `expired` | 审批有效期或操作有效期已过 |
|
||||
| `approved` | 执行 | `executing` | 重新鉴权、双批准仍有效、预览哈希和目标版本匹配 |
|
||||
| `executing` | 原子提交 | `succeeded` | 所有不变量通过,前后快照和执行事件同事务持久化 |
|
||||
| `executing` | 失败 | `failed` | 数据写入全部回滚;记录脱敏错误和关联追踪 ID |
|
||||
| `succeeded` | 申请回滚 | `rollback_pending` | 在可回滚窗口内,先预览回滚影响,重新双人批准 |
|
||||
| `rollback_pending` | 回滚提交 | `rolled_back` | 当前状态与回滚预览匹配,单事务恢复 |
|
||||
| `rollback_pending` | 回滚失败 | `rollback_failed` | 数据写入全部回滚并触发 P0 告警 |
|
||||
|
||||
审批建议 24 小时失效;支持访问不得因审批有效期而突破自身 `expires_at`。任何已批准操作在目标版本变化后不能直接执行,必须失效并重新预览、重新审批。
|
||||
|
||||
## 6. 标准操作流程
|
||||
|
||||
### 6.1 预览
|
||||
|
||||
预览是只读计算,不产生身份写入。输出至少包括:
|
||||
|
||||
- 规范化后的操作类型和目标引用
|
||||
- 受影响账号、工作空间、用户投影、成员关系、凭据与权限的脱敏摘要
|
||||
- 预计新增、更新、停用的实体清单
|
||||
- 操作前关键不变量检查
|
||||
- 冲突、阻断项和人工核验项
|
||||
- `expected_revisions`(实体 ID、`updated_at` 或数据库版本号)
|
||||
- 服务端基于规范化输入与预览结果计算的 `preview_hash`
|
||||
- 预览过期时间
|
||||
|
||||
预览不得返回密码哈希、令牌、完整合同内容、无关工作空间信息或超出审批所需的个人数据。
|
||||
|
||||
### 6.2 提交与审批
|
||||
|
||||
- 提交时带 `operation_id`、`preview_hash`、理由、工单引用和操作参数。
|
||||
- 后端重新计算规范化摘要,拒绝被篡改或过期的预览。
|
||||
- 两名审批人分别提交独立决定,记录时间、身份、MFA/会话强度、评论和客户端审计上下文。
|
||||
- 同一全局账号的不同工作空间用户投影不视为不同审批人。
|
||||
- 审批人失效、被撤销平台权限或审批过期时,该批准立即失效。
|
||||
- 对目标是某平台管理员本人的操作,该目标不得充当审批人。
|
||||
|
||||
### 6.3 执行
|
||||
|
||||
执行可由授权执行者发起,或在第二次批准后由受控 worker 发起。无论方式如何,执行者均不能代替审批人,且执行时必须重新做全部授权和不变量校验。
|
||||
|
||||
执行步骤:
|
||||
|
||||
1. 以 `operation_id` 读取操作并获取操作行锁。
|
||||
2. 若已有 `succeeded` / `rolled_back` 结果,直接返回原结果。
|
||||
3. 验证状态、有效期、审批人数、审批人当前资格和职责分离。
|
||||
4. 按确定顺序锁定全部受影响实体。
|
||||
5. 比较 `expected_revisions`,重新计算影响并匹配 `preview_hash`。
|
||||
6. 采集脱敏 `before_snapshot`。
|
||||
7. 执行身份变更并验证所有业务/数据库不变量。
|
||||
8. 采集脱敏 `after_snapshot`,写血缘、事件和业务审计。
|
||||
9. 在同一事务中把操作标记为 `succeeded` 并提交。
|
||||
10. 提交后执行会话撤销、缓存失效、通知和安全告警;失败时进入可靠重试队列,不能回滚已成功的身份事务。
|
||||
|
||||
## 7. API 草案
|
||||
|
||||
统一前缀:`/api/v1/identity/privileged-operations`。
|
||||
|
||||
### 7.1 端点
|
||||
|
||||
| 方法与路径 | 用途 | 最低权限 |
|
||||
| --- | --- | --- |
|
||||
| `POST /preview` | 只读影响预览 | 允许发起对应操作的平台管理员 |
|
||||
| `POST /` | 以 `operation_id` 提交操作 | 允许发起对应操作的平台管理员 |
|
||||
| `GET /{operation_id}` | 查询状态、审批与脱敏结果 | 请求人、审批人或授权审计人员 |
|
||||
| `POST /{operation_id}/approvals` | 批准或拒绝 | 非请求人的有效平台管理员 |
|
||||
| `POST /{operation_id}/execute` | 触发受控执行 | 独立执行权限或受控 worker |
|
||||
| `POST /{operation_id}/rollback` | 创建/推进回滚请求 | 平台管理员;仍需新一轮预览与双人批准 |
|
||||
|
||||
禁止提供可旁路统一流程的 `/merge`、`/split`、`/recover`、`/grant`、`/revoke` 或直接写支持授权端点。内部服务也只能调用统一执行器,不得直接更新身份表。
|
||||
|
||||
### 7.2 预览请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"operation_type": "account_merge",
|
||||
"target_refs": {
|
||||
"source_account_id": "acct_source",
|
||||
"target_account_id": "acct_target"
|
||||
},
|
||||
"evidence_refs": [
|
||||
{"type": "account_conflict_diagnostic", "id": "account-conflict-example"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 预览响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"preview_id": "preview_01",
|
||||
"operation_type": "account_merge",
|
||||
"preview_hash": "sha256:...",
|
||||
"expires_at": "2026-08-02T12:30:00Z",
|
||||
"expected_revisions": [
|
||||
{"entity_type": "identity_account", "entity_id": "acct_source", "revision": "2026-08-02T11:58:00Z"}
|
||||
],
|
||||
"impact_summary": {
|
||||
"accounts_updated": 2,
|
||||
"memberships_reassigned": 1,
|
||||
"credentials_disabled": 1,
|
||||
"workspace_business_records_moved": 0
|
||||
},
|
||||
"blocking_issues": [],
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 提交请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"operation_id": "op_01K...",
|
||||
"preview_id": "preview_01",
|
||||
"preview_hash": "sha256:...",
|
||||
"reason": "修复经用户和运营双向核验的重复账号",
|
||||
"ticket_reference": "SUP-2026-0802-001",
|
||||
"requested_expires_at": "2026-08-03T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 7.5 审批请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "approve",
|
||||
"comment": "已核对邮箱控制权、成员关系和工单证据",
|
||||
"preview_hash": "sha256:..."
|
||||
}
|
||||
```
|
||||
|
||||
### 7.6 HTTP 与错误语义
|
||||
|
||||
- `200`:幂等重试返回既有操作或结果。
|
||||
- `201`:首次创建操作。
|
||||
- `400`:请求结构或操作参数无效。
|
||||
- `403`:无发起/审批/执行权限,或违反职责分离。
|
||||
- `404`:目标不存在,或按最小披露策略对调用人不可见。
|
||||
- `409`:`operation_id` 被不同载荷占用、目标版本变化、唯一约束冲突或状态转换非法。
|
||||
- `410`:预览、审批或操作已过期。
|
||||
- `422`:预览存在未解决阻断项或业务不变量不成立。
|
||||
- `423`:相同目标存在执行中的互斥操作。
|
||||
|
||||
错误响应只返回稳定错误码、可行动摘要和 `trace_id`,不泄露其他账号或工作空间细节。
|
||||
|
||||
## 8. 数据结构草案
|
||||
|
||||
以下是后续实现阶段的逻辑结构,不代表本方案已授权创建迁移。
|
||||
|
||||
### 8.1 `privileged_identity_operations`
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `operation_id` | 全局唯一幂等键,主键 |
|
||||
| `operation_type` | 五类操作及明确的授予/撤销子类型 |
|
||||
| `status` | 统一状态机状态 |
|
||||
| `requested_by_account_id` | 全局请求人身份 |
|
||||
| `reason` | 必填业务/合规理由 |
|
||||
| `ticket_reference` | 必填外部工单或事件引用 |
|
||||
| `target_refs` | 规范化实体引用,不存展示型敏感信息 |
|
||||
| `evidence_refs` | 只读诊断等证据引用 |
|
||||
| `preview_id` / `preview_hash` | 已确认的预览及防篡改摘要 |
|
||||
| `preview_payload` | 脱敏影响摘要和阻断项 |
|
||||
| `expected_revisions` | 执行时乐观并发校验依据 |
|
||||
| `expires_at` | 操作/批准执行期限 |
|
||||
| `executed_by_account_id` | 实际执行者或受控 worker 身份 |
|
||||
| `result_summary` | 脱敏结果及受影响数量 |
|
||||
| `failure_code` / `trace_id` | 脱敏失败信息和排障关联 |
|
||||
| `created_at` / `updated_at` / `executed_at` | 时间线 |
|
||||
|
||||
`operation_id` 对规范化请求摘要建立唯一绑定。相同 ID、相同摘要返回原记录;相同 ID、不同摘要返回 `409 idempotency_conflict`。
|
||||
|
||||
### 8.2 `privileged_identity_operation_approvals`
|
||||
|
||||
- `id`
|
||||
- `operation_id`
|
||||
- `approver_account_id`
|
||||
- `decision = approve | reject`
|
||||
- `comment`
|
||||
- `preview_hash`
|
||||
- `approver_permission_revision`
|
||||
- `auth_strength` / `session_id_hash`
|
||||
- `decided_at` / `expires_at`
|
||||
|
||||
约束:每个全局审批人对同一操作只有一个有效决定;请求人不能成为审批人;只有两个不同的有效批准才满足执行门槛。
|
||||
|
||||
### 8.3 `privileged_identity_operation_snapshots`
|
||||
|
||||
- `id`
|
||||
- `operation_id`
|
||||
- `snapshot_type = before | after | rollback_before | rollback_after`
|
||||
- `schema_version`
|
||||
- `encrypted_payload` 或受控对象存储引用
|
||||
- `payload_hash`
|
||||
- `created_at`
|
||||
- `retention_until`
|
||||
|
||||
快照只保存恢复和审计所需字段;凭据仅记录 ID、scheme、状态、更新时间和不可逆指纹,不保存或导出密码哈希明文值。
|
||||
|
||||
### 8.4 `privileged_identity_operation_events`
|
||||
|
||||
追加式事件字段:`event_id`、`operation_id`、`event_type`、`actor_account_id`、`from_status`、`to_status`、`reason_code`、`metadata`、`trace_id`、`occurred_at`。不得更新或删除历史事件;更正通过追加事件表达。
|
||||
|
||||
### 8.5 `support_access_grants`
|
||||
|
||||
- `grant_id`
|
||||
- `operation_id`
|
||||
- `support_account_id`
|
||||
- `tenant_id`
|
||||
- `permission_scopes`
|
||||
- `reason` / `ticket_reference`
|
||||
- `status = scheduled | active | expired | revoked`
|
||||
- `starts_at` / `expires_at` / `revoked_at`
|
||||
- `created_at` / `updated_at`
|
||||
|
||||
访问判定必须直接或通过短 TTL 缓存核对该记录;缓存 TTL 不得晚于 `expires_at`。提前撤销属于紧急收权,可立即生效,但仍须写明原因并进入事后双人复核队列,不能借此扩大权限。
|
||||
|
||||
### 8.6 身份血缘
|
||||
|
||||
建议增加追加式 `identity_account_lineage`:记录 `operation_id`、`relationship_type`、`source_account_id`、`target_account_id`、`created_at`。用于解释合并、拆分和回滚历史,不作为登录授权来源。
|
||||
|
||||
## 9. 事务、并发与幂等
|
||||
|
||||
### 9.1 锁顺序
|
||||
|
||||
所有执行器必须按固定顺序和升序 ID 获取数据库锁,避免不同操作之间死锁:
|
||||
|
||||
1. `privileged_identity_operations`
|
||||
2. `identity_accounts`
|
||||
3. `identity_account_credentials`
|
||||
4. `tenants`
|
||||
5. `tenant_users`
|
||||
6. `workspace_memberships`
|
||||
7. `tenant_user_credentials`
|
||||
8. 平台管理员集合/权限承载记录
|
||||
9. `support_access_grants`
|
||||
|
||||
如果数据库不支持相同粒度的行锁,必须使用等价的事务级 advisory lock 或串行化执行队列;不得退化为无锁读后写。
|
||||
|
||||
### 9.2 并发规则
|
||||
|
||||
- 同一账号、工作空间用户、成员关系或管理员权限上只能有一个 `executing` 操作。
|
||||
- 预览采用乐观版本,执行采用行锁加版本复核。
|
||||
- 目标版本变化返回 `409 stale_preview`,不得自动套用旧预览。
|
||||
- 两个操作影响集合相交时,后获得锁的操作应失败并重新预览,不等待到审批期限之后静默执行。
|
||||
- 最后一名平台管理员检查必须与撤销写入处于同一事务和同一锁保护下。
|
||||
|
||||
### 9.3 幂等与外部副作用
|
||||
|
||||
- 身份数据库提交是幂等结果的事实来源。
|
||||
- 会话撤销、通知、缓存失效使用事务 outbox,消息键包含 `operation_id + effect_type`。
|
||||
- worker 重试先读取操作终态和 outbox 发送状态,不重复身份写入或重复通知。
|
||||
- `failed` 只表示身份事务未提交;提交后外部通知失败不得把操作改回 `failed`。
|
||||
|
||||
## 10. 回滚与事故处置
|
||||
|
||||
### 10.1 回滚规则
|
||||
|
||||
- 回滚不是隐藏的管理员按钮,而是一项新的受治理动作。
|
||||
- 回滚前生成反向影响预览,验证后续业务状态是否仍允许恢复。
|
||||
- 回滚需要新的 `rollback_operation_id`、理由、工单和两名不同平台管理员批准。
|
||||
- 原请求人仍不得批准;原审批人可否参与由安全策略决定,但至少应有一名未参与原执行的审批人。
|
||||
- 使用执行前快照和血缘恢复关系,仍执行当前唯一约束和管理员保底检查。
|
||||
- 已被后续合法操作修改的实体不能盲目覆盖;返回 `409 rollback_conflict` 并进入人工修复方案。
|
||||
|
||||
### 10.2 不完全可逆项
|
||||
|
||||
- 已发送通知不能撤回,只能发送更正通知。
|
||||
- 已访问或已导出的数据不能通过权限回滚消除,必须进入安全事件处置。
|
||||
- 密码凭据不得通过快照恢复哈希到另一主体;必要时强制重置。
|
||||
- 已过期支持访问通常不“回滚”为重新激活,只能创建新申请。
|
||||
|
||||
### 10.3 告警
|
||||
|
||||
以下事件触发 P0 安全/运营告警:最后管理员保护失败尝试、`rollback_failed`、审批绕过尝试、重复执行产生不同结果、支持访问过期后仍成功授权、快照哈希不一致、身份事务出现部分提交证据。
|
||||
|
||||
## 11. 审计、披露与留存
|
||||
|
||||
### 11.1 必须记录
|
||||
|
||||
- 请求人、审批人、执行者的全局账号 ID 和当时权限版本
|
||||
- 操作类型、理由、工单、目标实体 ID、预览哈希、版本集合
|
||||
- 每次状态转换、拒绝/取消原因、执行耗时与结果数量
|
||||
- 前后快照哈希、血缘、回滚关系和 `trace_id`
|
||||
- 支持访问的 scope、工作空间、开始/到期/撤销,以及所有使用该授权的敏感动作
|
||||
|
||||
### 11.2 最小披露
|
||||
|
||||
- 列表默认展示操作 ID、类型、状态、时间、脱敏目标、审批进度和工单号。
|
||||
- 详情按角色展示,不向审批人披露无关工作空间或合同内容。
|
||||
- 邮箱在列表中脱敏;需要核验完整值时使用单独授权动作并记录审计。
|
||||
- 永不展示密码哈希、会话令牌、重置令牌、API 密钥或快照加密材料。
|
||||
- 导出治理记录需独立导出权限、理由和审计,不得把快照原文作为普通 CSV 下载。
|
||||
|
||||
### 11.3 留存与完整性
|
||||
|
||||
操作、批准、事件和血缘记录应按合规策略长期留存;快照采用更严格访问控制和限定留存期。事件和快照哈希应进入现有审计完整性链或等价的防篡改机制。
|
||||
|
||||
## 12. 与现有工作空间模型的兼容方案
|
||||
|
||||
1. 保留所有现有 `tenant_id` 字段、外键和查询边界,不进行 `tenant -> workspace` 数据库重命名。
|
||||
2. API 和界面可使用“工作空间”,持久化映射仍指向 `TenantModel`。
|
||||
3. `workspace_type` 是产品语义依据:`personal` 表示个人工作空间,`organization` 表示组织工作空间,`system` 仅用于平台系统边界。
|
||||
4. 合并/拆分全局账号时只重映 `WorkspaceMembershipModel.account_id` 或创建明确的新关系,不改合同、模板、审查、审批等业务表的 `tenant_id`。
|
||||
5. `TenantUserModel` 继续作为工作空间内角色与展示投影;除非批准的恢复映射明确要求,否则保留其 ID。
|
||||
6. 现有实体 ID 默认全部保留。确需重映时,在预览中逐条列出,并通过血缘记录解释。
|
||||
7. 个人与组织工作空间的业务数据永不互并;同一全局账号拥有两类工作空间成员关系是合法状态。
|
||||
8. 兼容期同时检查全局凭据和旧工作空间凭据,避免合并后残留可登录入口或拆分后错误共享凭据。
|
||||
9. 现有 `diagnostic_id` 可放入 `evidence_refs`,但不是 `operation_id`,也不能授权任何写入。
|
||||
10. 停用/墓碑优先于删除,以兼容既有外键、历史审计和回滚。
|
||||
|
||||
## 13. 验收标准
|
||||
|
||||
### 13.1 通用门槛
|
||||
|
||||
- [ ] 所有五类操作都必须先获得未过期预览,提交时校验 `preview_hash`。
|
||||
- [ ] 相同 `operation_id`、相同请求重试返回完全相同的操作和结果;不同请求返回 `409`。
|
||||
- [ ] 请求人审批自己的请求返回 `403`,不产生有效批准。
|
||||
- [ ] 同一审批人重复批准只计一次。
|
||||
- [ ] 只有两名不同、执行时仍有效的平台管理员批准后才能执行。
|
||||
- [ ] 批准过期、审批人权限撤销、预览过期或目标版本变化均阻止执行。
|
||||
- [ ] 执行过程中任一写入/不变量失败时,身份实体无部分变化。
|
||||
- [ ] 成功操作具备前后快照、状态事件、操作者、批准者、工单和追踪 ID。
|
||||
- [ ] 回滚经过独立预览、幂等 ID 和双人批准,不能直接覆盖后续合法变更。
|
||||
- [ ] 所有列表/详情/错误响应通过敏感数据披露测试。
|
||||
- [ ] 代码库不存在绕过统一执行器的同类破坏性身份写路由。
|
||||
|
||||
### 13.2 操作专项门槛
|
||||
|
||||
- [ ] 账号合并不改变任何业务记录 `tenant_id`,不复制平台管理员权限,并停用多余凭据。
|
||||
- [ ] 账号拆分按明确映射移动成员关系,不复制密码哈希,不复制平台管理员权限。
|
||||
- [ ] 成员恢复验证 `tenant_user.tenant_id`,且不提升角色或 owner 状态。
|
||||
- [ ] 平台管理员撤销在并发条件下仍保证至少一名有效管理员。
|
||||
- [ ] 支持访问必须有理由、工单、单工作空间、scope 和到期时间。
|
||||
- [ ] 支持访问到期后,在线请求、旧会话和缓存命中都不能继续授权。
|
||||
- [ ] 支持访问延期必须创建新的已批准操作。
|
||||
|
||||
## 14. 回归测试清单
|
||||
|
||||
### 14.1 权限与职责分离
|
||||
|
||||
- [ ] 普通个人用户、组织成员、工作空间管理员均不能预览或提交特权身份操作。
|
||||
- [ ] 工作空间管理员不能因 `role=admin` 获得平台审批能力。
|
||||
- [ ] 请求人使用另一工作空间投影审批仍被识别为同一全局账号并拒绝。
|
||||
- [ ] 被停用或刚撤权的平台管理员不能批准/执行。
|
||||
- [ ] 前端隐藏绕过、直接 API 调用、篡改会话声明均被服务端拒绝。
|
||||
|
||||
### 14.2 状态机与幂等
|
||||
|
||||
- [ ] 每个合法状态转换成功,每个非法转换返回稳定错误码。
|
||||
- [ ] 并发提交相同 `operation_id` 只创建一条记录。
|
||||
- [ ] 并发执行相同操作只提交一次,其他请求返回原结果。
|
||||
- [ ] 相同 ID 不同载荷触发幂等冲突。
|
||||
- [ ] `rejected`、`cancelled`、`expired`、`failed` 不能继续执行。
|
||||
- [ ] 服务重启和 worker 重试不重复写入或通知。
|
||||
|
||||
### 14.3 预览与并发
|
||||
|
||||
- [ ] 修改任一目标实体后,旧预览执行返回 `stale_preview`。
|
||||
- [ ] 两个交叉影响账号/成员关系的操作不会死锁或部分提交。
|
||||
- [ ] 最后两名管理员被并发撤销时最多一个操作成功,且仍保留一名有效管理员。
|
||||
- [ ] 唯一约束冲突发生时整个身份事务回滚。
|
||||
- [ ] 锁超时返回可重试错误,不把操作错误标记为成功。
|
||||
|
||||
### 14.4 合并、拆分与恢复
|
||||
|
||||
- [ ] 合并覆盖无重叠成员、同工作空间重叠成员、停用成员、owner 成员和旧凭据并存场景。
|
||||
- [ ] 个人与组织工作空间同时存在时,合并前后业务数据归属不变。
|
||||
- [ ] 拆分覆盖新账号、既有账号、邮箱占用、未验证邮箱和凭据重置场景。
|
||||
- [ ] 拆分映射遗漏或重复成员关系时预览阻断。
|
||||
- [ ] 恢复覆盖重激活、重新关联、重建以及目标已被占用场景。
|
||||
- [ ] owner 恢复时 `owner_user_id` 不一致会阻断或明确进入审批差异。
|
||||
|
||||
### 14.5 支持访问
|
||||
|
||||
- [ ] 缺理由、缺工单、空 scope、跨工作空间或超最大期限均拒绝。
|
||||
- [ ] scope 外页面、API、下载和导出返回 `403`。
|
||||
- [ ] 到期、提前撤销、支持账号停用后立即失效。
|
||||
- [ ] 授权缓存 TTL 不超过 `expires_at`。
|
||||
- [ ] 每次敏感访问审计均含 `grant_id`,且不泄露无关内容。
|
||||
- [ ] 原地延期请求被拒绝;新申请需重新双批准。
|
||||
|
||||
### 14.6 回滚、审计与披露
|
||||
|
||||
- [ ] 合并、拆分、恢复和管理员权限变更在无后续冲突时可按快照回滚。
|
||||
- [ ] 存在后续合法修改时回滚返回冲突,不覆盖新数据。
|
||||
- [ ] 回滚失败不留下部分恢复状态,并触发 P0 告警。
|
||||
- [ ] 前后快照哈希可验证,审计事件只追加不可修改。
|
||||
- [ ] API 响应、日志、事件、通知和导出均不含密码哈希、令牌或无关敏感字段。
|
||||
- [ ] 账号冲突 `diagnostic_id` 只能作为证据引用,不能用来调用执行接口。
|
||||
|
||||
## 15. 分阶段 Backlog
|
||||
|
||||
### P0:先建立不可绕过的治理底座
|
||||
|
||||
1. 冻结并扫描直接身份破坏性写入口,定义统一执行器边界。
|
||||
2. 固化五类操作 schema、状态机、状态转换权限和稳定错误码。
|
||||
3. 实现只读预览、规范化载荷、`preview_hash` 和 `expected_revisions`。
|
||||
4. 实现 `operation_id` 幂等请求、双人批准、审批过期和职责分离。
|
||||
5. 实现数据库事务、固定锁顺序、最后管理员并发保护和 outbox。
|
||||
6. 实现追加式事件、脱敏前后快照、血缘和审计完整性接入。
|
||||
7. 实现支持访问服务端授权检查、自动到期和紧急撤销。
|
||||
8. 建立 P0 自动化测试:审批绕过、重复执行、部分提交、最后管理员和过期后访问。
|
||||
|
||||
P0 发布门槛:没有任何旁路写 API;事务/并发故障注入通过;支持访问到期后不可继续访问;安全和运营共同签字。
|
||||
|
||||
### P1:受控运营台与完整回滚
|
||||
|
||||
1. 建设操作列表、预览差异、审批、执行状态和审计详情页面。
|
||||
2. 对接只读账号冲突诊断,一键带入证据引用,但不自动提交操作。
|
||||
3. 实现回滚预览、独立审批、冲突检测与人工事故工作台。
|
||||
4. 完善支持访问 scope 模型、业务审计关联和用户通知。
|
||||
5. 接入工单系统引用校验、审批提醒、过期提醒和结果通知。
|
||||
6. 增加跨数据库、worker 重试、断电恢复和高并发回归。
|
||||
|
||||
P1 发布门槛:五类操作均可在受控运营台完成端到端演练;回滚演练和审计导出满足合规验收;界面无敏感信息过度披露。
|
||||
|
||||
### P2:规模化运营与持续监督
|
||||
|
||||
1. 风险分级、异常模式检测和高风险操作二次安全挑战。
|
||||
2. 审批 SLA、过期率、失败率、回滚率、支持访问使用率仪表盘。
|
||||
3. 定期复核平台管理员名册、孤儿成员关系和未关闭支持授权。
|
||||
4. 为治理策略提供版本化配置、灰度与审计,不允许降低 P0 硬约束。
|
||||
5. 建立季度灾难恢复演练、权限抽样复核和审计完整性证明。
|
||||
|
||||
## 16. 上线与运营门禁
|
||||
|
||||
- 设计评审:产品、身份后端、安全、数据库、运营、合规共同确认操作不变量。
|
||||
- 数据评审:迁移方案必须独立审批;本设计文档不授权创建表或修改现有数据。
|
||||
- 预生产演练:使用合成账号覆盖五类操作、并发、失败注入和回滚。
|
||||
- 灰度:先仅允许指定平台治理小组使用,所有执行触发实时告警。
|
||||
- 生产门禁:P0 测试全绿,旁路扫描无结果,审计完整性校验通过,回滚手册完成演练。
|
||||
- 日常运营:每周复核失败/过期操作,每月复核管理员与支持授权,每季度演练回滚。
|
||||
- 停止线:出现部分提交、越权执行、过期访问、最后管理员失守或审计缺失,立即停用执行入口,只保留查询和事故处置。
|
||||
|
||||
## 17. 明确非目标
|
||||
|
||||
- 本阶段不修改 `TenantModel`、`tenant_id` 或现有业务表的数据归属。
|
||||
- 本阶段仅实现治理记录迁移、只读预览、操作登记、查询、审批页面与审批状态机;不实现身份数据执行、回滚或修复。
|
||||
- 本阶段不自动根据冲突诊断执行合并或恢复。
|
||||
- 本阶段不把组织管理员提升为平台管理员。
|
||||
- 本阶段不允许支持人员无审批代入用户身份,也不共享用户密码或会话。
|
||||
|
||||
本文件是后续自动化开发的治理输入。任何实现任务必须先从 P0 底座拆分技术设计与迁移评审,并以本文件的验收标准和回归清单作为发布门槛。
|
||||
|
||||
## 18. 第一阶段实现检查点(2026-08-02)
|
||||
|
||||
已实现且可进入持续回归:
|
||||
|
||||
- 平台管理员专属的六类只读影响预览;预览返回规范化目标、`preview_hash`、`expected_revisions`、影响摘要、警告和 15 分钟有效期。
|
||||
- 持久化治理操作与审批记录。首次登记返回 `201`;相同 `operation_id` 与相同载荷重放返回原记录和 `200`;不同载荷复用同一 ID 返回稳定 `409`。
|
||||
- 请求人与审批人职责分离;请求人不能审批自己的操作;同一全局账号重复审批只计一次;两个不同且当前有效的平台管理员批准后状态进入 `approved`;任一拒绝进入 `rejected`。
|
||||
- 同一审批人的并发重放通过唯一约束和冲突回读保持幂等,不产生重复审批或未处理的 `500`。过期状态当前采用读时投影,列表筛选与详情返回一致。
|
||||
- 管理页面提供预览、登记、列表、详情与批准/拒绝入口,并持续展示“治理记录不执行身份变更”。没有 `/execute`,也没有直接合并、拆分、恢复、授予、撤销或支持访问写路由。
|
||||
- 迁移 `20260802_0037` 只新增 `privileged_identity_operations` 和 `privileged_identity_operation_approvals`,不修改账号、工作空间、成员关系、凭据、平台权限或业务数据归属。
|
||||
|
||||
本检查点不授权把 `approved` 解释为已执行。以下能力仍是后续发布门槛:追加式状态事件与 `trace_id`、脱敏 before/after 快照、真实 PostgreSQL 并发和锁验证、身份执行器、outbox、支持访问在线鉴权与自动到期、回滚预览/审批/演练、外部工单与通知接入。上述门槛完成前,身份数据执行入口必须保持关闭。
|
||||
|
||||
## 19. 第二阶段实现与验收检查点(2026-08-02)
|
||||
|
||||
已完成:
|
||||
|
||||
- 迁移 `20260802_0038` 新增追加式 `privileged_identity_operation_events`,包含单操作递增序号、事件类型、状态 before/after、actor、`trace_id`、脱敏快照、前后快照哈希和时间戳。
|
||||
- 提交、审批、拒绝和过期物化在操作事务内追加事件;幂等重放不重复追加。过期操作由首次相关读取或审批触发物化,后续读取不重复产生 `operation_expired`。
|
||||
- 快照只保存操作状态、计数和目标引用指纹;敏感字段名和值均不进入快照。快照哈希可由返回快照重新计算验证。
|
||||
- 新增平台管理员专属只读事件接口和前端事件时间线;时间线展示状态转换、`trace_id` 和脱敏摘要,不暴露原始目标值。
|
||||
- 真实 PostgreSQL 临时 schema 并发测试通过,覆盖并发幂等提交、同审批人去重、不同审批人有序批准、拒绝终态、过期唯一事件、追踪与快照完整性及最后管理员保护;测试 schema 已清理,公共迁移基线已恢复且没有公共事件表残留。
|
||||
- Contract API 全量 `220 passed, 1 skipped`,真实 PostgreSQL 专项单独 `1 passed`;Web 定向 `28/28`、TypeScript、Python 编译、101 路由生产构建和 `git diff --check` 均通过。
|
||||
|
||||
继续关闭:
|
||||
|
||||
- 身份执行器、账号合并/拆分/恢复实际写入、平台权限授予/撤销实际写入和支持访问授权实际写入。
|
||||
- outbox、执行失败补偿、跨实体固定锁顺序、执行前 `expected_revisions` 再校验、回滚状态机与回滚演练。
|
||||
- 支持访问在线鉴权、自动到期与紧急撤销,真实工单系统校验、审批提醒、结果通知和审计导出。
|
||||
|
||||
因此 `approved` 仍只能解释为“审批完成、等待未来受控执行器”,不能解释为身份数据已经变更。
|
||||
|
||||
## 20. 第二阶段最终交付复核(2026-08-02)
|
||||
|
||||
- 已登录平台管理员浏览器打开合成治理记录 `op_browser_events_20260802`,事件时间线完整展示追加式序号、状态转换、`trace_id`、脱敏计数和快照哈希前缀。桌面全页视觉复核未发现重叠,页面控制台 error/warn 为 `0`。
|
||||
- 独立质量复核后新增迁移 `20260802_0039`,由 SQLite/PostgreSQL 数据库触发器拒绝治理事件 `UPDATE/DELETE`;本地自动建库路径安装等价保护。列表与详情统一映射过期物化等治理异常,避免极端并发冲突退化为 `500`。
|
||||
- 最终本地回归为治理专项 `10/10`、Web 全部定向 Node 测试 `34/34`、TypeScript 与 Python 编译通过;Next.js 生产构建生成 `101` 个页面/路由,`git diff --check` 无错误。
|
||||
- 运行时健康复核确认 Web `3021`、Contract API `8110`、AI Platform `8111` 均返回 `200`;运行中 OpenAPI 的特权身份路径仅包括 preview、create/list、detail、events 和 approvals,破坏性身份写路径数量为 `0`。
|
||||
- Alembic 代码 head 为 `20260802_0039`。真实 PostgreSQL 专项使用临时 schema 验证不可变触发器,测试结束后清理,不把测试迁移或事件表遗留在公共 schema。
|
||||
|
||||
本检查点完成后仍不得把审批状态机扩展为直接身份写入。下一阶段必须以独立设计和验收任务建设统一执行器、固定锁顺序、执行前版本复核、outbox、失败补偿、支持访问在线鉴权/到期/紧急撤销,以及独立回滚预览和双人审批。
|
||||
@@ -468,3 +468,66 @@ legal_name: string | null
|
||||
- **P1 PostgreSQL 性能与并发验收**:在共享预发布 PostgreSQL 上记录账号候选 SQL 的 `EXPLAIN (ANALYZE, BUFFERS)`、排序规则和高冲突基数基线,按证据决定是否新增规范化邮箱列或表达式索引;同时执行最后有效平台管理员保护的真实双请求并发测试。当前 SQLite、SQL 编译和分片测试不能替代该验收。
|
||||
- **P1 高权限写操作治理**:账号合并、拆分、成员恢复以及平台管理员授予/撤销仍必须先定义 preview、幂等 operation ID、支持访问原因与时限、双人审批、回滚策略和 before/after 审计,不得从当前只读诊断直接扩展为无审批破坏性写接口。
|
||||
- **P2 产品表达与自动化**:工作空间类型在单空间和多空间状态下仍需以独立“系统/个人/组织”标签一致表达;平台专属诊断页应增加前端精确路由守卫和真实 Route Handler 测试,覆盖当前权限、已撤权、旧会话和后端不可用四种状态。
|
||||
|
||||
### 9.23 第八阶段自动化开发结果(2026-08-02)
|
||||
|
||||
- **工作空间类型一致表达**:签名 Web 会话兼容携带 `workspace_type`;单空间顶栏显示“系统/个人/组织工作空间”,多空间选择器为每一项显示“系统/个人/组织”标签。`default` 继续映射为系统工作空间,技术 `tenant_id` 和既有业务外键不变。旧会话缺少类型时,前端优先采用 `/api/auth/workspaces` 返回的当前工作空间类型,避免个人空间在最长七天的旧 cookie 生命周期内被误标为组织。
|
||||
- **实时平台权限为最终依据**:组织代开 BFF 不再让陈旧 cookie 权限反向否决后端实时授权。登录后新授予权限可立即通过实时平台名册校验,已撤权、后端不可用或实时校验失败均 fail closed 返回 `403`;未登录返回 `401`。会话权限只承担快速入口和路由预判,不能替代服务端实时授权。
|
||||
- **平台身份路由可达性**:平台管理员即使当前工作空间角色不是 `admin`,只要签名会话仍携带 `platform_admin` 声明,也可先进入 `/admin/identity/**`,再由页面和 BFF 执行实时平台权限复核;撤权后的陈旧声明最多允许到达守卫,不会读取跨工作空间数据或执行组织代开。
|
||||
- **冲突页精确守卫**:`/admin/identity/conflicts` 通过可执行保护资源编排先验证平台管理员名册,再加载冲突列表和详情。未授权或实时校验异常时,不调用任何冲突列表或详情函数,页面仅显示统一拒绝状态;授权成功后仍由每个后端接口独立执行平台权限校验。
|
||||
- **高权限写操作治理方案**:新增 `V2-privileged-identity-operations-governance-2026-08-02.md`,覆盖账号合并、账号拆分、成员关系恢复、平台管理员授予/撤销和受限支持访问五类操作,定义 preview、幂等 operation ID、双人审批、状态机、事务并发、回滚、before/after 审计、P0/P1/P2 Backlog、验收标准及回归清单。本阶段没有新增破坏性身份写接口、数据库迁移或生产操作。
|
||||
|
||||
### 9.24 第八阶段验证证据与后续优先项
|
||||
|
||||
- Web 定向回归 `27/27` 通过,覆盖登录入口、会话映射、工作空间类型回退、平台路由可达性、新授予、已撤权、旧会话、后端不可用、未授权不加载诊断数据和组织代开保护。TypeScript 类型检查通过,Next.js 生产构建通过并生成 100 个页面/路由;`git diff --check` 通过,仅有工作区既有 CRLF 提示。
|
||||
- 已登录平台管理员浏览器刷新复核 `/workspace`、`/admin/identity` 和 `/admin/identity/conflicts`:顶栏显示“系统工作空间”,身份管理同时展示系统、个人和组织标签,组织代开表单只对当前平台管理员可见,冲突页显示严重 `0`、提醒 `2`;控制台无应用 error,代码热更新后仅出现一次开发环境 Fast Refresh 全量刷新 warning。Web、Contract API、AI Platform 分别在 `3021`、`8110`、`8111` 返回 `200`。
|
||||
- **P0 法律签批与共享预发布交付**仍是发布阻断:具名法务复核、正式法律版本、生效记录、批准责任人和共享环境验收运行 ID 尚未完成,技术回归和浏览器实测不能替代法律批准。
|
||||
- **P1 特权身份操作实现**:下一阶段按治理方案先实现只读 preview、幂等操作记录和审批状态机,再实现双人审批后的受控执行;回滚和 before/after 审计未通过验收前,不开放账号合并、拆分、成员恢复、平台权限授予/撤销或支持访问写操作。
|
||||
- **P1 真实外部依赖验收**:仍需在共享 PostgreSQL 完成候选 SQL `EXPLAIN (ANALYZE, BUFFERS)`、最后有效平台管理员双请求并发验证,并在实际邮件服务上完成送达、退信、重试和限流。当前 SQLite、本地浏览器和本机假 SMTP 证据不能替代这些验收。
|
||||
- **P2 Route Handler 集成覆盖**:当前授权核心和保护资源执行顺序已有运行时纯函数测试,仍应增加真实 Next.js Route Handler 级测试,覆盖签名 cookie 解析、代理头、`401/403` 状态码和成功转发,作为后续重构的回归保护。
|
||||
|
||||
### 9.25 第九阶段自动化开发结果(2026-08-02)
|
||||
|
||||
- **只读特权操作预览**:新增平台管理员专属 `POST /api/v1/identity/privileged-operations/preview`,覆盖账号合并、账号拆分、成员关系恢复、平台管理员授予/撤销和限时支持访问六类预览。响应包含规范化目标、证据引用、`preview_hash`、`expected_revisions`、影响摘要、警告和到期时间;预览不修改账号、成员关系、凭据或权限。
|
||||
- **幂等治理操作记录**:新增 `privileged_identity_operations`。首次登记返回 `201`,同一操作 ID 与相同载荷重试返回原记录和 `200`,不同载荷复用同一 ID 返回 `409 idempotency_conflict`。提交时重新计算预览并校验哈希和版本,过期或陈旧预览不能登记。
|
||||
- **双人审批状态机**:新增 `privileged_identity_operation_approvals`。请求人不能自审批;审批人按全局账号去重;同一审批人重复同一决定保持幂等,不同决定返回冲突;两个不同且当前有效的平台管理员批准后进入 `approved`,任一拒绝进入 `rejected`。同审批人并发重放由唯一约束和冲突回读收口,不产生重复记录或 `500`。
|
||||
- **执行边界保持关闭**:后端只注册 preview、create、list、detail 和 approvals;没有 `/execute`、rollback、merge、split、recover、grant 或 revoke 身份写路由。前端只提供预览、治理登记和审批,不提供身份执行按钮;`approved` 仅表示审批完成,不改变任何身份数据。
|
||||
- **治理运营页面**:新增 `/admin/identity/privileged-operations`,支持六类动态预览字段、预览影响、审批登记、记录列表、详情和批准/拒绝;身份管理页只在平台管理员区域展示入口。客户端请求移入浏览器专用服务层,避免客户端组件导入依赖 `next/headers` 的服务端模块。
|
||||
- **平台权限与旧会话收口**:实时平台管理员名册继续作为组织代开的最终授权依据;身份治理路径允许携带平台声明但工作空间角色非 `admin` 的管理员先进入,再由页面/BFF/后端实时复核。单空间类型优先使用工作空间 API 结果,旧会话缺少 `workspace_type` 时不再把个人空间误标为组织。
|
||||
- **兼容现有工作空间模型**:迁移 `20260802_0037` 仅新增治理操作和审批表;`TenantModel`、`tenant_id`、个人/组织工作空间、全局账号、成员关系、凭据及业务数据归属均保持不变。账号合并只允许在未来执行阶段重映全局身份/成员归属,不得合并个人与组织工作空间业务数据。
|
||||
|
||||
### 9.26 第九阶段验证证据与后续优先项
|
||||
|
||||
- Contract API 使用全新隔离 SQLite 执行治理与迁移专项 `9/9`、身份/权限组合回归 `45/45`、全量 `220/220`;Python 编译通过。全新数据库从空库升级到 Alembic 单一 head `20260802_0037`,迁移专项同时覆盖 downgrade 和再次 upgrade。
|
||||
- Web 联合定向回归 `34/34` 通过;客户端模块边界修复后页面专项 `2/2` 和 TypeScript 再次通过。Next.js 生产构建成功生成 `101` 个页面/路由,包含 `/admin/identity/privileged-operations`;`git diff --check` 无错误,仅有既有 LF/CRLF 提示。
|
||||
- 本地运行库 `.runtime/contract-api-20260802.sqlite3` 已先备份到 `.runtime/db-backups/contract-api-20260802-before-0037-20260802-1900.sqlite3`。该历史库由 `create_all` 建立且没有 Alembic 版本表;确认其具备 `0036` 身份表且不含治理表后,先标记 `0036`,再只执行 `0037`。Contract API 重新加载后,Web `3021`、Contract API `8110`、AI Platform `8111` 均返回 `200`;Web 与 AI 服务未重启,现有登录会话保留。
|
||||
- 已登录浏览器验收确认身份管理页存在“特权操作”入口;治理页展示六类预览、动态字段、执行关闭说明、空记录状态和禁用的审批提交初态。对现有普通成员执行平台管理员授予的只读预览,成功显示预览 ID、可提交状态和 `platform_permissions_changed=1`,但没有登记操作;尝试预览撤销唯一平台管理员时稳定阻断。页面全部交互控件只有返回、生成预览、提交双人审批和刷新,没有身份执行或直接写按钮;控制台 error/warning 为 `0`。
|
||||
- **P0 发布阻断仍不变**:具名法务签批、正式法律版本与共享预发布验收运行 ID 尚未完成;本阶段身份治理能力不改变 AI 黄金门禁、外部邮件交付或法律正确性状态。
|
||||
- **P1 治理审计与执行器**:下一步先增加追加式状态事件、稳定 `trace_id`、脱敏 before/after 快照和审计完整性,再在真实 PostgreSQL 验证并发提交、审批唯一约束、锁顺序和最后管理员保护。上述完成前不得实现或开放身份执行器。
|
||||
- **P1 执行与回滚后续阶段**:账号合并、拆分、成员恢复、平台权限变更和支持访问必须通过统一执行器、固定锁顺序、版本复核、outbox、在线授权检查、自动到期和独立回滚审批;任何直连身份表的管理写接口继续作为 P0 阻断项。
|
||||
- **P1/P2 运营缺口**:仍需真实工单校验、审批提醒、结果通知、审计导出、支持访问使用审计、审批 SLA/过期率仪表盘,以及真实邮件服务送达/退信/重试和全局邮箱恢复旅程。
|
||||
|
||||
### 9.27 第十阶段自动化开发结果(2026-08-02)
|
||||
|
||||
- **追加式治理状态事件**:新增迁移 `20260802_0038` 和 `privileged_identity_operation_events`。每个操作使用唯一 `(operation_id, sequence_no)` 形成递增事件序列;提交、审批、拒绝和过期物化均与操作状态在同一事务追加事件。幂等提交重放和同审批人重复决定不会重复追加事件。
|
||||
- **请求追踪持久化**:治理事件保存既有请求上下文 `trace_id`。标准追踪值原样保存;异常或不受信任格式只保存不可逆指纹,避免把任意请求头写入审计记录。事件只读接口为 `GET /api/v1/identity/privileged-operations/{operation_id}/events`。
|
||||
- **脱敏审计快照**:`snapshot_before` / `snapshot_after` 仅保存存在标记、操作类型、状态、审批计数、引用数量、告警数量和目标引用指纹,不保存邮箱、姓名、凭据、token、secret、password 或原始目标值;前后快照分别保存 SHA-256 哈希供完整性复核。
|
||||
- **过期状态落库**:到期的 `pending_approval` 或 `approved` 操作在首次列表、详情、事件读取或审批触发时,在行锁保护下物化为 `expired` 并追加一次 `operation_expired`;后续读取不重复写事件。
|
||||
- **只读审计时间线**:特权操作详情页新增事件时间线,展示事件类型、状态转换、序号、时间、`trace_id`、脱敏计数摘要和快照哈希前缀。页面仍没有身份执行、回滚、合并、拆分、恢复、授予或撤销按钮。
|
||||
- **身份执行器继续关闭**:后端仍只注册 preview、create、list、detail、events 和 approvals;`approved` 只表示双人审批完成,不修改账号、成员关系、凭据、平台权限、支持访问授权或业务数据。
|
||||
|
||||
### 9.28 第十阶段验证证据与后续优先项
|
||||
|
||||
- SQLite 治理与迁移专项 `9/9` 通过;Contract API 全量 `220 passed, 1 skipped`,跳过项为需要显式连接参数的 PostgreSQL 专项。该 PostgreSQL 专项随后在 `192.168.3.90:15432` 的唯一临时 schema 中单独执行并 `1/1` 通过。
|
||||
- 真实 PostgreSQL 证据覆盖:并发相同操作只创建 1 条操作和 1 条提交事件;同一审批人并发只创建 1 条审批和 1 条决定事件;两个不同审批人并发形成两条有序事件并最终进入 `approved`;拒绝保持终态;过期只物化一次;事件序号、`trace_id`、快照哈希和脱敏规则均通过;最后有效平台管理员撤销预览继续被阻断。
|
||||
- PostgreSQL 测试按阶段运行 Alembic 到单一 head `20260802_0038`,仅在 `pg_trgm` 索引迁移期间临时把 `public` 加入 `search_path`,其余迁移和运行时连接只使用测试 schema。夹具在 `finally` 中精确删除测试 schema。诊断试跑曾只推进公共 Alembic 标记而未创建事件表;已恢复公共标记为原基线 `20260802_0037`,确认公共事件表不存在、残留治理/诊断 schema 数量为 `0`。
|
||||
- Web 治理定向回归 `28/28`、TypeScript 和 Python 编译通过;Next.js 生产构建成功生成 `101` 个页面/路由;`git diff --check` 无错误,仅有既有 LF/CRLF 提示。Alembic 当前代码 head 为 `20260802_0038`。
|
||||
- **后续 P1 门槛**:统一身份执行器、执行前版本复核、固定实体锁顺序、outbox、支持访问在线鉴权与自动到期、失败补偿、独立回滚预览/审批/演练、真实工单校验和通知仍未实现。在这些门槛通过前,身份执行器必须继续关闭。
|
||||
|
||||
### 9.29 第十阶段最终交付复核(2026-08-02)
|
||||
|
||||
- 已登录平台管理员浏览器复核 `/admin/identity/privileged-operations` 的合成治理记录 `op_browser_events_20260802`。详情按纵向分组显示“登记操作”、`初始状态 -> 待审批`、序号/时间、脱敏审批与引用计数、`trace_id: trace-browser-submit-20260802` 和快照哈希前缀;桌面全页截图未发现文字重叠或元数据挤压,控制台 error/warn 为 `0`。
|
||||
- 浏览器页面持续明确“治理记录不执行身份变更”,且只存在 preview、登记、列表、详情、events 与 approvals 交互。运行中 Contract API OpenAPI 复核得到破坏性身份写路径数量 `0`,未暴露 execute、rollback、merge、split、recover、grant 或 revoke 路径。
|
||||
- 独立质量复核后新增迁移 `20260802_0039`:SQLite 使用两个拒绝触发器、PostgreSQL 使用单一 `BEFORE UPDATE OR DELETE` 触发器,数据库层直接修改或删除治理事件均失败;本地自动建库路径同步安装等价保护。列表与详情也统一映射过期物化等治理异常,避免极端并发冲突退化为 `500`。
|
||||
- 最终本地复核新增执行 Web 全部 `*.test.mjs`,结果 `34/34`;治理迁移、不可变触发器、错误映射与操作专项在隔离内存数据库下 `10/10`;TypeScript、Python 编译、101 路由 Next.js 生产构建和 `git diff --check` 通过。Web `3021`、Contract API `8110`、AI Platform `8111` 均返回 `200`,Alembic 代码为单一 head `20260802_0039`。
|
||||
- 本阶段交付结论不变:追加式事件、追踪、脱敏快照和真实 PostgreSQL 并发门槛已完成;身份执行器仍为关闭状态。下一阶段不得直接开放破坏性写入口,必须先单独评审统一执行器、固定锁顺序、版本复核、outbox、失败补偿、支持访问在线鉴权和独立回滚治理。
|
||||
|
||||
+34
-21
@@ -3,11 +3,13 @@ import { AlertTriangle, ArrowLeft, CheckCircle2, Search, ShieldCheck } from "luc
|
||||
import {
|
||||
getAccountConflict,
|
||||
getAccountConflicts,
|
||||
getPlatformAdministrators,
|
||||
type AccountConflictCode,
|
||||
type AccountConflictDetail,
|
||||
type AccountConflictItem,
|
||||
type AccountConflictSeverity,
|
||||
} from "@/services/contracts";
|
||||
import { loadPlatformProtectedResource } from "@/lib/platform-protected-resource";
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: Promise<{
|
||||
@@ -48,29 +50,21 @@ export default async function AccountConflictPage({ searchParams }: PageProps) {
|
||||
const severity = isSeverity(query?.severity) ? query?.severity : undefined;
|
||||
const conflictCode = isConflictCode(query?.conflict_code) ? query?.conflict_code : undefined;
|
||||
const email = query?.email?.trim() || undefined;
|
||||
const [conflictPage, detail] = await Promise.all([
|
||||
getAccountConflicts({ page, page_size: pageSize, severity, conflict_code: conflictCode, email }),
|
||||
query?.id ? getAccountConflict(query.id) : Promise.resolve(null),
|
||||
]);
|
||||
const protectedData = await loadPlatformProtectedResource(
|
||||
async () => Boolean(await getPlatformAdministrators()),
|
||||
async () => {
|
||||
const [conflictPage, detail] = await Promise.all([
|
||||
getAccountConflicts({ page, page_size: pageSize, severity, conflict_code: conflictCode, email }),
|
||||
query?.id ? getAccountConflict(query.id) : Promise.resolve(null),
|
||||
]);
|
||||
return conflictPage ? { conflictPage, detail } : null;
|
||||
},
|
||||
);
|
||||
|
||||
if (!conflictPage) {
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<h1>账号冲突诊断</h1>
|
||||
<p>该工具仅供平台管理员查看,企业工作空间管理员不会看到跨工作空间身份数据。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="empty-state empty-state-warning">
|
||||
<ShieldCheck size={20} />
|
||||
<strong>当前会话无法读取平台诊断</strong>
|
||||
<span>请确认当前账号具有平台管理员权限,或稍后重试诊断服务。</span>
|
||||
<Link className="button button-primary" href="/admin/identity">返回身份管理</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
if (!protectedData.allowed) {
|
||||
return <PlatformAccessDenied />;
|
||||
}
|
||||
const { conflictPage, detail } = protectedData.resource;
|
||||
|
||||
const currentQuery = buildQuery({ page, page_size: pageSize, severity, conflict_code: conflictCode, email });
|
||||
const totalPages = Math.max(1, Math.ceil(conflictPage.total / conflictPage.page_size));
|
||||
@@ -159,6 +153,25 @@ export default async function AccountConflictPage({ searchParams }: PageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformAccessDenied() {
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<h1>账号冲突诊断</h1>
|
||||
<p>该工具仅供平台管理员查看,企业工作空间管理员不会看到跨工作空间身份数据。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="empty-state empty-state-warning">
|
||||
<ShieldCheck size={20} />
|
||||
<strong>当前会话无法读取平台诊断</strong>
|
||||
<span>请确认当前账号具有平台管理员权限,或稍后重试诊断服务。</span>
|
||||
<Link className="button button-primary" href="/admin/identity">返回身份管理</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConflictRow({ item, query }: { item: AccountConflictItem; query: URLSearchParams }) {
|
||||
return (
|
||||
<Link className="list-item" href={`/admin/identity/conflicts?${withFilter(query, { id: item.diagnostic_id })}`}>
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ test("organization workspace provisioning is gated by platform identity manageme
|
||||
assert.match(pageSource, /\{canCreateOrganizationWorkspace && <form/);
|
||||
assert.match(identityProxySource, /denyUnauthorizedOrganizationProvisioning\(path\)/);
|
||||
assert.match(identityProxySource, /platform-administrators/);
|
||||
assert.match(identityProxySource, /canManagePlatformIdentity\(session\.platform_permissions, liveAccess\)/);
|
||||
assert.match(identityProxySource, /authorizeOrganizationProvisioning\(/);
|
||||
const provisionFormStart = pageSource.indexOf("{canCreateOrganizationWorkspace && <form");
|
||||
const provisionFormEnd = pageSource.indexOf("</form>}", provisionFormStart);
|
||||
const memberManagementStart = pageSource.indexOf("<span>新增用户</span>", provisionFormEnd);
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { canManagePlatformIdentity, getRoleLabel, type UserRole } from "@/lib/access-control";
|
||||
import { useContractSession } from "@/components/app-shell";
|
||||
import { getIdentityCapabilities, type IdentityCapabilities } from "@/lib/identity-capabilities";
|
||||
import { workspaceDisplayName } from "@/lib/workspace-access-state";
|
||||
import { workspaceDisplayName, workspaceTypeLabel } from "@/lib/workspace-access-state";
|
||||
|
||||
type TenantStatus = TenantInfo["status"];
|
||||
type TenantUserStatus = TenantUserItem["status"];
|
||||
@@ -543,7 +543,7 @@ export default function IdentityAdminPage() {
|
||||
</div>
|
||||
<div className="status-pill">
|
||||
当前管理空间:{selectedTenant
|
||||
? workspaceDisplayName(selectedTenant.id, selectedTenant.name)
|
||||
? `${workspaceDisplayName(selectedTenant.id, selectedTenant.name)} · ${workspaceTypeLabel(selectedTenant.id, selectedTenant.workspace_type)}`
|
||||
: selectedTenantId}
|
||||
</div>
|
||||
</div>
|
||||
@@ -574,10 +574,16 @@ export default function IdentityAdminPage() {
|
||||
只读治理视图;共 {platformAdministrators.total} 人,当前有效 {platformAdministrators.active_total} 人。
|
||||
</p>
|
||||
</div>
|
||||
<Link className="button button-soft" href="/admin/identity/conflicts">
|
||||
<Search size={16} />
|
||||
账号冲突诊断
|
||||
</Link>
|
||||
<div className="toolbar">
|
||||
<Link className="button button-soft" href="/admin/identity/privileged-operations">
|
||||
<ShieldCheck size={16} />
|
||||
特权操作
|
||||
</Link>
|
||||
<Link className="button button-soft" href="/admin/identity/conflicts">
|
||||
<Search size={16} />
|
||||
账号冲突诊断
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel-body identity-user-list">
|
||||
{platformAdministrators.items.map((administrator) => (
|
||||
@@ -620,6 +626,7 @@ export default function IdentityAdminPage() {
|
||||
<strong>{workspaceDisplayName(tenant.id, tenant.name)}</strong>
|
||||
<span className="muted">{tenant.id} / {tenant.plan ?? "-"}</span>
|
||||
</span>
|
||||
<span className="tag">{workspaceTypeLabel(tenant.id, tenant.workspace_type)}</span>
|
||||
<span className={`tag tag-${tenant.status === "active" ? "success" : tenant.status === "suspended" ? "warning" : "neutral"}`}>
|
||||
{tenantStatusLabels[tenant.status]}
|
||||
</span>
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const pageSource = await readFile(new URL("./page.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("the governance page exposes preview, records, and approval without execution", () => {
|
||||
assert.match(pageSource, /from "@\/services\/contracts-client"/);
|
||||
assert.match(pageSource, /import type \{[\s\S]*?\} from "@\/services\/contracts"/);
|
||||
assert.match(pageSource, /previewPrivilegedIdentityOperation/);
|
||||
assert.match(pageSource, /createPrivilegedIdentityOperation/);
|
||||
assert.match(pageSource, /decidePrivilegedIdentityOperation/);
|
||||
assert.match(pageSource, /getPrivilegedIdentityOperationEvents/);
|
||||
assert.match(pageSource, /状态事件时间线/);
|
||||
assert.match(pageSource, /trace_id/);
|
||||
assert.match(pageSource, /snapshot_after_hash/);
|
||||
assert.match(pageSource, /snapshotSummary/);
|
||||
assert.match(pageSource, /身份数据执行入口保持关闭/);
|
||||
assert.doesNotMatch(pageSource, /\/execute/);
|
||||
assert.doesNotMatch(pageSource, /\/merge|\/split|\/recover|\/grant|\/revoke/);
|
||||
});
|
||||
|
||||
test("approval comments are required for both decisions", () => {
|
||||
assert.match(pageSource, /approvalComment\.trim\(\)\.length < 3/g);
|
||||
assert.match(pageSource, /decide\("approve"\)/);
|
||||
assert.match(pageSource, /decide\("reject"\)/);
|
||||
});
|
||||
+482
@@ -0,0 +1,482 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Activity,
|
||||
Check,
|
||||
Eye,
|
||||
LockKeyhole,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
createPrivilegedIdentityOperation,
|
||||
decidePrivilegedIdentityOperation,
|
||||
getPrivilegedIdentityOperation,
|
||||
getPrivilegedIdentityOperationEvents,
|
||||
getPrivilegedIdentityOperations,
|
||||
previewPrivilegedIdentityOperation,
|
||||
} from "@/services/contracts-client";
|
||||
import type {
|
||||
PrivilegedIdentityOperation,
|
||||
PrivilegedIdentityOperationEvent,
|
||||
PrivilegedIdentityOperationType,
|
||||
PrivilegedIdentityPreview,
|
||||
} from "@/services/contracts";
|
||||
import {
|
||||
buildPrivilegedTargetRefs,
|
||||
hasRequiredPrivilegedTargetRefs,
|
||||
privilegedApprovalProgress,
|
||||
privilegedOperationDefinitions,
|
||||
privilegedOperationStatusLabels,
|
||||
} from "@/lib/privileged-identity-operation-state";
|
||||
|
||||
const operationTypes = Object.keys(privilegedOperationDefinitions) as PrivilegedIdentityOperationType[];
|
||||
|
||||
export default function PrivilegedIdentityOperationsPage() {
|
||||
const [operationType, setOperationType] = useState<PrivilegedIdentityOperationType>("account_merge");
|
||||
const [targetValues, setTargetValues] = useState<Record<string, string>>({});
|
||||
const [diagnosticId, setDiagnosticId] = useState("");
|
||||
const [preview, setPreview] = useState<PrivilegedIdentityPreview | null>(null);
|
||||
const [operationId, setOperationId] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [ticketReference, setTicketReference] = useState("");
|
||||
const [requestedExpiresAt, setRequestedExpiresAt] = useState("");
|
||||
const [operations, setOperations] = useState<PrivilegedIdentityOperation[]>([]);
|
||||
const [selected, setSelected] = useState<PrivilegedIdentityOperation | null>(null);
|
||||
const [events, setEvents] = useState<PrivilegedIdentityOperationEvent[]>([]);
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
const [approvalComment, setApprovalComment] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const definition = privilegedOperationDefinitions[operationType];
|
||||
const targetRefs = useMemo(
|
||||
() => buildPrivilegedTargetRefs(operationType, targetValues),
|
||||
[operationType, targetValues],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshOperations();
|
||||
}, []);
|
||||
|
||||
async function refreshOperations() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const page = await getPrivilegedIdentityOperations();
|
||||
setOperations(page?.items ?? []);
|
||||
if (selected) {
|
||||
const [refreshed, eventPage] = await Promise.all([
|
||||
getPrivilegedIdentityOperation(selected.operation_id),
|
||||
getPrivilegedIdentityOperationEvents(selected.operation_id),
|
||||
]);
|
||||
setSelected(refreshed);
|
||||
setEvents(eventPage?.items ?? []);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runPreview(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!hasRequiredPrivilegedTargetRefs(operationType, targetRefs)) {
|
||||
setMessage("请完整填写目标引用。");
|
||||
return;
|
||||
}
|
||||
setBusy("preview");
|
||||
setMessage(null);
|
||||
try {
|
||||
const result = await previewPrivilegedIdentityOperation({
|
||||
operation_type: operationType,
|
||||
target_refs: targetRefs,
|
||||
evidence_refs: diagnosticId.trim()
|
||||
? [{ type: "account_conflict_diagnostic", id: diagnosticId.trim() }]
|
||||
: [],
|
||||
});
|
||||
setPreview(result);
|
||||
setOperationId(`op_${crypto.randomUUID().replaceAll("-", "")}`);
|
||||
setMessage(result.blocking_issues.length
|
||||
? "预览存在阻断项,不能提交审批。"
|
||||
: "预览已生成,可登记审批请求。");
|
||||
} catch (error) {
|
||||
setPreview(null);
|
||||
setMessage(error instanceof Error ? error.message : "预览生成失败。");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOperation(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!preview || preview.blocking_issues.length) {
|
||||
setMessage("请先生成无阻断项的有效预览。");
|
||||
return;
|
||||
}
|
||||
setBusy("submit");
|
||||
setMessage(null);
|
||||
try {
|
||||
const created = await createPrivilegedIdentityOperation({
|
||||
operation_id: operationId.trim(),
|
||||
preview_hash: preview.preview_hash,
|
||||
preview_expires_at: preview.expires_at,
|
||||
reason: reason.trim(),
|
||||
ticket_reference: ticketReference.trim(),
|
||||
requested_expires_at: requestedExpiresAt
|
||||
? new Date(requestedExpiresAt).toISOString()
|
||||
: undefined,
|
||||
operation_type: preview.operation_type,
|
||||
target_refs: preview.target_refs,
|
||||
evidence_refs: preview.evidence_refs ?? [],
|
||||
});
|
||||
setSelected(created);
|
||||
const eventPage = await getPrivilegedIdentityOperationEvents(created.operation_id);
|
||||
setEvents(eventPage?.items ?? []);
|
||||
setPreview(null);
|
||||
setReason("");
|
||||
setTicketReference("");
|
||||
setRequestedExpiresAt("");
|
||||
setMessage("治理操作已登记,等待两名独立平台管理员审批。");
|
||||
await refreshOperations();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "治理操作登记失败。");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectOperation(operation: PrivilegedIdentityOperation) {
|
||||
setBusy(`load:${operation.operation_id}`);
|
||||
setEventsLoading(true);
|
||||
try {
|
||||
const [detail, eventPage] = await Promise.all([
|
||||
getPrivilegedIdentityOperation(operation.operation_id),
|
||||
getPrivilegedIdentityOperationEvents(operation.operation_id),
|
||||
]);
|
||||
setSelected(detail);
|
||||
setEvents(eventPage?.items ?? []);
|
||||
setApprovalComment("");
|
||||
} finally {
|
||||
setEventsLoading(false);
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function decide(decision: "approve" | "reject") {
|
||||
if (!selected) return;
|
||||
setBusy(decision);
|
||||
setMessage(null);
|
||||
try {
|
||||
const updated = await decidePrivilegedIdentityOperation(selected.operation_id, {
|
||||
decision,
|
||||
comment: approvalComment.trim(),
|
||||
preview_hash: selected.preview_hash,
|
||||
});
|
||||
setSelected(updated);
|
||||
setApprovalComment("");
|
||||
setMessage(decision === "approve" ? "审批意见已记录。" : "拒绝意见已记录。");
|
||||
await refreshOperations();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "审批提交失败。");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-layout identity-admin-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<h1>特权身份操作</h1>
|
||||
<p>预览影响、登记操作并完成双人审批;身份数据执行入口保持关闭。</p>
|
||||
</div>
|
||||
<Link className="button button-soft" href="/admin/identity">
|
||||
<ArrowLeft size={16} /> 返回身份管理
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="empty-state empty-state-warning">
|
||||
<LockKeyhole size={20} />
|
||||
<strong>治理记录不执行身份变更</strong>
|
||||
<span>账号、成员关系、平台权限和支持访问不会在本页面被修改。</span>
|
||||
</div>
|
||||
{message && <div className="empty-state identity-message">{message}</div>}
|
||||
|
||||
<div className="identity-layout">
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Preview</p>
|
||||
<h2 className="panel-title">只读影响预览</h2>
|
||||
</div>
|
||||
<Eye size={20} />
|
||||
</div>
|
||||
<form className="panel-body identity-form" onSubmit={runPreview}>
|
||||
<label>
|
||||
<span>操作类型</span>
|
||||
<select
|
||||
value={operationType}
|
||||
onChange={(event) => {
|
||||
setOperationType(event.target.value as PrivilegedIdentityOperationType);
|
||||
setTargetValues({});
|
||||
setPreview(null);
|
||||
}}
|
||||
>
|
||||
{operationTypes.map((value) => (
|
||||
<option key={value} value={value}>{privilegedOperationDefinitions[value].label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{definition.fields.map((field) => (
|
||||
<label key={field.key}>
|
||||
<span>{field.label}</span>
|
||||
{field.format === "json" ? <textarea
|
||||
onChange={(event) => setTargetValues((values) => ({
|
||||
...values,
|
||||
[field.key]: event.target.value,
|
||||
}))}
|
||||
placeholder={field.placeholder}
|
||||
required
|
||||
value={targetValues[field.key] ?? ""}
|
||||
/> : <input
|
||||
onChange={(event) => setTargetValues((values) => ({
|
||||
...values,
|
||||
[field.key]: event.target.value,
|
||||
}))}
|
||||
placeholder={field.placeholder}
|
||||
required
|
||||
value={targetValues[field.key] ?? ""}
|
||||
/>}
|
||||
</label>
|
||||
))}
|
||||
<label>
|
||||
<span>冲突诊断 ID</span>
|
||||
<input
|
||||
onChange={(event) => setDiagnosticId(event.target.value)}
|
||||
placeholder="account-conflict-id,可留空"
|
||||
value={diagnosticId}
|
||||
/>
|
||||
</label>
|
||||
<button className="button button-primary" disabled={busy !== null} type="submit">
|
||||
<Eye size={16} /> {busy === "preview" ? "正在预览..." : "生成预览"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{preview && (
|
||||
<div className="panel-body list">
|
||||
<div className="list-item">
|
||||
<span><strong>预览 ID</strong><span className="muted">{preview.preview_id}</span></span>
|
||||
<span className={`tag ${preview.blocking_issues.length ? "tag-danger" : "tag-success"}`}>
|
||||
{preview.blocking_issues.length ? `${preview.blocking_issues.length} 项阻断` : "可提交"}
|
||||
</span>
|
||||
</div>
|
||||
{Object.entries(preview.impact_summary).map(([key, value]) => (
|
||||
<div className="list-item" key={key}><span>{key}</span><strong>{value}</strong></div>
|
||||
))}
|
||||
{preview.blocking_issues.map((issue) => (
|
||||
<div className="empty-state empty-state-error" key={issue}>{issue}</div>
|
||||
))}
|
||||
{preview.warnings.map((warning) => (
|
||||
<div className="empty-state empty-state-warning" key={warning}>{warning}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Approval request</p>
|
||||
<h2 className="panel-title">登记审批请求</h2>
|
||||
</div>
|
||||
<ShieldCheck size={20} />
|
||||
</div>
|
||||
<form className="panel-body identity-form" onSubmit={submitOperation}>
|
||||
<label>
|
||||
<span>操作 ID</span>
|
||||
<input onChange={(event) => setOperationId(event.target.value)} required value={operationId} />
|
||||
</label>
|
||||
<label>
|
||||
<span>工单编号</span>
|
||||
<input onChange={(event) => setTicketReference(event.target.value)} required value={ticketReference} />
|
||||
</label>
|
||||
<label>
|
||||
<span>审批截止时间</span>
|
||||
<input
|
||||
onChange={(event) => setRequestedExpiresAt(event.target.value)}
|
||||
type="datetime-local"
|
||||
value={requestedExpiresAt}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>申请理由</span>
|
||||
<textarea onChange={(event) => setReason(event.target.value)} required value={reason} />
|
||||
</label>
|
||||
<button
|
||||
className="button button-primary"
|
||||
disabled={!preview || preview.blocking_issues.length > 0 || busy !== null}
|
||||
type="submit"
|
||||
>
|
||||
<ShieldCheck size={16} /> {busy === "submit" ? "正在登记..." : "提交双人审批"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Operations</p>
|
||||
<h2 className="panel-title">治理操作记录</h2>
|
||||
</div>
|
||||
<button className="button button-soft" disabled={loading || busy !== null} onClick={() => void refreshOperations()} type="button">
|
||||
<RefreshCw size={16} /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body list">
|
||||
{!loading && operations.length === 0 && <div className="empty-state">暂无治理操作记录。</div>}
|
||||
{operations.map((operation) => (
|
||||
<button
|
||||
className="list-item identity-tenant-card"
|
||||
disabled={busy !== null}
|
||||
key={operation.operation_id}
|
||||
onClick={() => void selectOperation(operation)}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
<strong>{privilegedOperationDefinitions[operation.operation_type].label}</strong>
|
||||
<span className="muted">{operation.operation_id} · {operation.ticket_reference}</span>
|
||||
</span>
|
||||
<span className="tag">审批 {privilegedApprovalProgress(operation.approval_count, 2)}</span>
|
||||
<span className={`tag ${statusTone(operation.status)}`}>
|
||||
{privilegedOperationStatusLabels[operation.status]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{selected && (
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Operation detail</p>
|
||||
<h2 className="panel-title">{selected.operation_id}</h2>
|
||||
<p className="muted">{selected.reason} · {selected.ticket_reference}</p>
|
||||
</div>
|
||||
<span className={`tag ${statusTone(selected.status)}`}>
|
||||
{privilegedOperationStatusLabels[selected.status]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="panel-body list">
|
||||
<div className="list-item"><span>请求账号</span><strong>{selected.requested_by_account_id}</strong></div>
|
||||
<div className="list-item"><span>审批进度</span><strong>{privilegedApprovalProgress(selected.approval_count, 2)}</strong></div>
|
||||
<div className="list-item"><span>到期时间</span><strong>{formatDate(selected.expires_at)}</strong></div>
|
||||
{selected.approvals.map((approval) => (
|
||||
<div className="list-item" key={`${approval.approver_account_id}:${approval.decided_at}`}>
|
||||
<span>
|
||||
<strong>{approval.approver_account_id}</strong>
|
||||
<span className="muted">{approval.comment || "未填写意见"}</span>
|
||||
</span>
|
||||
<span className={`tag ${approval.decision === "approve" ? "tag-success" : "tag-danger"}`}>
|
||||
{approval.decision === "approve" ? "批准" : "拒绝"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="panel-body list">
|
||||
<div className="list-item-header">
|
||||
<div>
|
||||
<p className="eyebrow">Append-only audit trail</p>
|
||||
<h3 className="panel-title">状态事件时间线</h3>
|
||||
</div>
|
||||
<Activity size={18} />
|
||||
</div>
|
||||
{eventsLoading && <div className="empty-state">正在加载审计事件...</div>}
|
||||
{!eventsLoading && events.length === 0 && (
|
||||
<div className="empty-state">暂无追加式状态事件。</div>
|
||||
)}
|
||||
{!eventsLoading && events.map((event) => (
|
||||
<div className="list-item identity-event-item" key={event.event_id}>
|
||||
<span className="identity-event-copy">
|
||||
<strong>{eventLabel(event.event_type)}</strong>
|
||||
<span className="muted">
|
||||
#{event.sequence_no} · {formatDate(event.created_at)}
|
||||
</span>
|
||||
<span className="muted">
|
||||
{event.status_before
|
||||
? `${privilegedOperationStatusLabels[event.status_before]} → `
|
||||
: "初始状态 → "}
|
||||
{privilegedOperationStatusLabels[event.status_after]}
|
||||
</span>
|
||||
<span className="muted">
|
||||
{snapshotSummary(event.snapshot_after)}
|
||||
</span>
|
||||
<span className="muted">
|
||||
trace_id: {event.trace_id || "未提供"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="tag tag-primary">
|
||||
快照 {event.snapshot_after_hash.slice(0, 12)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selected.status === "pending_approval" && (
|
||||
<div className="panel-body identity-form">
|
||||
<label>
|
||||
<span>审批意见</span>
|
||||
<textarea onChange={(event) => setApprovalComment(event.target.value)} value={approvalComment} />
|
||||
</label>
|
||||
<div className="toolbar">
|
||||
<button className="button button-primary" disabled={approvalComment.trim().length < 3 || busy !== null} onClick={() => void decide("approve")} type="button">
|
||||
<Check size={16} /> 批准
|
||||
</button>
|
||||
<button className="button button-soft" disabled={approvalComment.trim().length < 3 || busy !== null} onClick={() => void decide("reject")} type="button">
|
||||
<X size={16} /> 拒绝
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusTone(status: PrivilegedIdentityOperation["status"]) {
|
||||
if (status === "approved") return "tag-success";
|
||||
if (status === "rejected" || status === "expired" || status === "cancelled") return "tag-danger";
|
||||
if (status === "pending_approval") return "tag-warning";
|
||||
return "tag-primary";
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
timeZone: "Asia/Shanghai",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function eventLabel(eventType: PrivilegedIdentityOperationEvent["event_type"]) {
|
||||
return {
|
||||
operation_submitted: "登记操作",
|
||||
approval_recorded: "记录审批",
|
||||
operation_rejected: "拒绝操作",
|
||||
operation_expired: "操作过期",
|
||||
}[eventType];
|
||||
}
|
||||
|
||||
function snapshotSummary(snapshot: PrivilegedIdentityOperationEvent["snapshot_after"]) {
|
||||
return [
|
||||
`审批 ${snapshot.approval_count ?? 0}`,
|
||||
`目标引用 ${snapshot.target_reference_count ?? 0}`,
|
||||
`证据 ${snapshot.evidence_reference_count ?? 0}`,
|
||||
`告警 ${snapshot.warning_count ?? 0}`,
|
||||
].join(" · ");
|
||||
}
|
||||
+13
-12
@@ -1,4 +1,3 @@
|
||||
import { canManagePlatformIdentity } from "@/lib/access-control";
|
||||
import {
|
||||
buildContractApiUrl,
|
||||
buildIdentityProxyHeaders,
|
||||
@@ -6,6 +5,7 @@ import {
|
||||
proxyContractApiResponse,
|
||||
} from "@/lib/contract-api";
|
||||
import { getContractWebSession } from "@/lib/contract-session";
|
||||
import { authorizeOrganizationProvisioning } from "@/lib/platform-provisioning-access";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ path?: string[] }>;
|
||||
@@ -40,18 +40,19 @@ export async function POST(request: Request, { params }: RouteContext) {
|
||||
}
|
||||
|
||||
async function denyUnauthorizedOrganizationProvisioning(path: string[] | undefined) {
|
||||
if (path?.join("/") !== "register-company") {
|
||||
return null;
|
||||
}
|
||||
const session = await getContractWebSession();
|
||||
if (!session) {
|
||||
return Response.json({ message: "请先登录。" }, { status: 401 });
|
||||
}
|
||||
const liveAccess = await fetchContractApi(
|
||||
buildContractApiUrl("/api/v1/identity/platform-administrators"),
|
||||
).then((response) => response.ok).catch(() => false);
|
||||
if (!canManagePlatformIdentity(session.platform_permissions, liveAccess)) {
|
||||
return Response.json({ message: "仅平台管理员可以代开组织工作空间。" }, { status: 403 });
|
||||
const decision = await authorizeOrganizationProvisioning(
|
||||
path,
|
||||
session,
|
||||
async () => {
|
||||
const response = await fetchContractApi(
|
||||
buildContractApiUrl("/api/v1/identity/platform-administrators"),
|
||||
);
|
||||
return response.ok;
|
||||
},
|
||||
);
|
||||
if (!decision.allowed) {
|
||||
return Response.json({ message: decision.message }, { status: decision.status });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2105,6 +2105,21 @@ textarea {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.identity-event-item {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.identity-event-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.identity-event-copy .muted {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.identity-selected {
|
||||
border-color: var(--line-strong);
|
||||
background: var(--surface-tinted);
|
||||
@@ -4828,6 +4843,7 @@ details.panel summary::-webkit-details-marker {
|
||||
.contract-action-stack,
|
||||
.workspace-task-item,
|
||||
.identity-tenant-card,
|
||||
.identity-event-item,
|
||||
.identity-user-row,
|
||||
.identity-field-grid {
|
||||
align-items: stretch;
|
||||
|
||||
@@ -18,7 +18,7 @@ export function AdminAccessGuard({
|
||||
const currentRole = normalizeUserRole(session?.role);
|
||||
const checkedPath = path ?? pathname;
|
||||
|
||||
if (canAccessAdminPath(checkedPath, currentRole)) {
|
||||
if (canAccessAdminPath(checkedPath, currentRole, session?.platform_permissions)) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,9 +76,16 @@ export function AppShell({ children, session }: { children: ReactNode; session:
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const currentRole = normalizeUserRole(session?.role);
|
||||
const visibleAssetNav = assetNav.filter((item) => !item.restricted || canAccessAdminPath(item.href, currentRole));
|
||||
const visibleAdminNav = adminNav.filter((item) => canAccessAdminPath(item.href, currentRole));
|
||||
const visibleOpsNav = opsNav.filter((item) => canAccessAdminPath(item.href, currentRole));
|
||||
const visibleAssetNav = assetNav.filter(
|
||||
(item) => !item.restricted
|
||||
|| canAccessAdminPath(item.href, currentRole, session?.platform_permissions),
|
||||
);
|
||||
const visibleAdminNav = adminNav.filter(
|
||||
(item) => canAccessAdminPath(item.href, currentRole, session?.platform_permissions),
|
||||
);
|
||||
const visibleOpsNav = opsNav.filter(
|
||||
(item) => canAccessAdminPath(item.href, currentRole, session?.platform_permissions),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || isPublicShellPath(pathname)) {
|
||||
|
||||
@@ -6,7 +6,14 @@ import { useRouter } from "next/navigation";
|
||||
import { Building2, MailWarning } from "lucide-react";
|
||||
import type { ContractWebSession } from "@/lib/contract-session";
|
||||
import { getWorkspaceAccess, switchWorkspace, type WorkspaceAccess } from "@/lib/workspace-access";
|
||||
import { activeWorkspaceAccess, canSwitchWorkspace, workspaceDisplayName } from "@/lib/workspace-access-state";
|
||||
import {
|
||||
activeWorkspaceAccess,
|
||||
canSwitchWorkspace,
|
||||
currentWorkspaceType,
|
||||
workspaceDisplayName,
|
||||
workspaceTypeDescription,
|
||||
workspaceTypeLabel,
|
||||
} from "@/lib/workspace-access-state";
|
||||
|
||||
export function WorkspaceSwitcher({ session }: { session: ContractWebSession }) {
|
||||
const router = useRouter();
|
||||
@@ -64,7 +71,14 @@ export function WorkspaceSwitcher({ session }: { session: ContractWebSession })
|
||||
<Building2 size={15} />
|
||||
<span>
|
||||
<strong>{workspaceDisplayName(session.tenant_id, session.tenant_name)}</strong>
|
||||
<small>{error ? "工作空间列表暂不可用" : session.display_name}</small>
|
||||
<small>
|
||||
{error
|
||||
? "工作空间列表暂不可用"
|
||||
: `${workspaceTypeDescription(
|
||||
session.tenant_id,
|
||||
currentWorkspaceType(items, session.tenant_id, session.workspace_type),
|
||||
)} · ${session.display_name}`}
|
||||
</small>
|
||||
</span>
|
||||
</span>
|
||||
{!loading && !error && !session.email_verified_at && (
|
||||
@@ -90,7 +104,7 @@ export function WorkspaceSwitcher({ session }: { session: ContractWebSession })
|
||||
{items.map((item) => (
|
||||
<option key={item.membership.id} value={item.workspace.id}>
|
||||
{workspaceDisplayName(item.workspace.id, item.workspace.name)}
|
||||
{item.workspace.workspace_type === "personal" ? "(个人)" : ""}
|
||||
{`(${workspaceTypeLabel(item.workspace.id, item.workspace.workspace_type)})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { canManagePlatformIdentity, hasPlatformCapability } from "./access-control.ts";
|
||||
import {
|
||||
canAccessAdminPath,
|
||||
canManagePlatformIdentity,
|
||||
hasPlatformCapability,
|
||||
} from "./access-control.ts";
|
||||
|
||||
test("platform identity management requires the explicit platform permission", () => {
|
||||
assert.equal(hasPlatformCapability(["platform_admin"], "platform.identity.manage"), true);
|
||||
assert.equal(hasPlatformCapability([], "platform.identity.manage"), false);
|
||||
});
|
||||
|
||||
test("platform identity management also requires a live platform access check", () => {
|
||||
test("the live platform access check is authoritative over stale session claims", () => {
|
||||
assert.equal(canManagePlatformIdentity(["platform_admin"], true), true);
|
||||
assert.equal(canManagePlatformIdentity(["platform_admin"], false), false);
|
||||
assert.equal(canManagePlatformIdentity([], true), false);
|
||||
assert.equal(canManagePlatformIdentity([], true), true);
|
||||
assert.equal(canManagePlatformIdentity(undefined, true), true);
|
||||
assert.equal(canManagePlatformIdentity(undefined, false), false);
|
||||
});
|
||||
|
||||
test("platform administrators can reach identity pages before the live page guard", () => {
|
||||
assert.equal(canAccessAdminPath("/admin/identity", "legal", ["platform_admin"]), true);
|
||||
assert.equal(canAccessAdminPath("/admin/identity/conflicts", "business", ["platform_admin"]), true);
|
||||
assert.equal(canAccessAdminPath("/admin/identity", "legal", []), false);
|
||||
assert.equal(canAccessAdminPath("/admin/ai-models", "business", ["platform_admin"]), false);
|
||||
assert.equal(canAccessAdminPath("/health", "business", ["platform_admin"]), false);
|
||||
});
|
||||
|
||||
test("server and client session guards pass platform claims into the identity precheck", async () => {
|
||||
const [proxySource, guardSource, shellSource] = await Promise.all([
|
||||
readFile(new URL("../proxy.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../components/admin-access-guard.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../components/app-shell.tsx", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(proxySource, /canAccessAdminPath\(pathname, normalizeUserRole\(session\.role\), session\.platform_permissions\)/);
|
||||
assert.match(guardSource, /canAccessAdminPath\(checkedPath, currentRole, session\?\.platform_permissions\)/);
|
||||
assert.match(shellSource, /canAccessAdminPath\(item\.href, currentRole, session\?\.platform_permissions\)/);
|
||||
});
|
||||
|
||||
@@ -100,7 +100,17 @@ export function getRoleLabel(role: UserRole): string {
|
||||
return roleLabels[role];
|
||||
}
|
||||
|
||||
export function canAccessAdminPath(pathname: string, role: UserRole): boolean {
|
||||
export function canAccessAdminPath(
|
||||
pathname: string,
|
||||
role: UserRole,
|
||||
platformPermissions?: readonly string[],
|
||||
): boolean {
|
||||
if (
|
||||
(pathname === "/admin/identity" || pathname.startsWith("/admin/identity/"))
|
||||
&& hasPlatformCapability(platformPermissions, "platform.identity.manage")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (pathname === "/admin") {
|
||||
return role === "admin" || role === "legal";
|
||||
}
|
||||
@@ -144,12 +154,8 @@ export function hasPlatformCapability(
|
||||
}
|
||||
|
||||
export function canManagePlatformIdentity(
|
||||
platformPermissions: readonly string[] | undefined,
|
||||
_platformPermissions: readonly string[] | undefined,
|
||||
platformAccessValidated: boolean,
|
||||
): boolean {
|
||||
if (!platformAccessValidated) {
|
||||
return false;
|
||||
}
|
||||
return platformPermissions === undefined
|
||||
|| hasPlatformCapability(platformPermissions, "platform.identity.manage");
|
||||
return platformAccessValidated;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
|
||||
export type ContractWebSession = {
|
||||
tenant_id: string;
|
||||
tenant_name: string;
|
||||
workspace_type?: "system" | "personal" | "organization";
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
@@ -43,6 +44,9 @@ export function readContractWebSession(rawValue: string | null | undefined): Con
|
||||
return {
|
||||
tenant_id: parsed.tenant_id,
|
||||
tenant_name: parsed.tenant_name || parsed.tenant_id,
|
||||
workspace_type: ["system", "personal", "organization"].includes(parsed.workspace_type ?? "")
|
||||
? parsed.workspace_type
|
||||
: undefined,
|
||||
user_id: parsed.user_id,
|
||||
email: parsed.email,
|
||||
display_name: parsed.display_name || parsed.email,
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from "node:test";
|
||||
import { isIdentitySessionPayload, toContractWebSession } from "./identity-session-mapping.ts";
|
||||
|
||||
const basePayload = {
|
||||
tenant: { id: "workspace-personal", name: "我的工作空间" },
|
||||
tenant: { id: "workspace-personal", name: "我的工作空间", workspace_type: "personal" },
|
||||
user: {
|
||||
id: "user-current",
|
||||
email: "user@example.com",
|
||||
@@ -23,6 +23,7 @@ test("session mapping carries the global email verification timestamp", () => {
|
||||
assert.deepEqual(toContractWebSession(payload), {
|
||||
tenant_id: "workspace-personal",
|
||||
tenant_name: "我的工作空间",
|
||||
workspace_type: "personal",
|
||||
user_id: "user-current",
|
||||
email: "user@example.com",
|
||||
display_name: "Current User",
|
||||
|
||||
@@ -4,6 +4,7 @@ export type IdentitySessionPayload = {
|
||||
tenant: {
|
||||
id: string;
|
||||
name: string;
|
||||
workspace_type?: "system" | "personal" | "organization";
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
@@ -39,6 +40,7 @@ export function toContractWebSession(payload: IdentitySessionPayload): ContractW
|
||||
return {
|
||||
tenant_id: payload.tenant.id,
|
||||
tenant_name: payload.tenant.name,
|
||||
workspace_type: payload.tenant.workspace_type,
|
||||
user_id: payload.user.id,
|
||||
email: payload.user.email,
|
||||
display_name: payload.user.display_name,
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { loadPlatformProtectedResource } from "./platform-protected-resource.ts";
|
||||
|
||||
test("denied platform access never loads the protected resource", async () => {
|
||||
let loaded = false;
|
||||
const decision = await loadPlatformProtectedResource(
|
||||
async () => false,
|
||||
async () => {
|
||||
loaded = true;
|
||||
return { value: "sensitive" };
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(decision, { allowed: false });
|
||||
assert.equal(loaded, false);
|
||||
});
|
||||
|
||||
test("platform validation failure is fail-closed before loading data", async () => {
|
||||
let loaded = false;
|
||||
const decision = await loadPlatformProtectedResource(
|
||||
async () => {
|
||||
throw new Error("backend unavailable");
|
||||
},
|
||||
async () => {
|
||||
loaded = true;
|
||||
return { value: "sensitive" };
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(decision, { allowed: false });
|
||||
assert.equal(loaded, false);
|
||||
});
|
||||
|
||||
test("authorized platform access loads and returns the protected resource", async () => {
|
||||
const decision = await loadPlatformProtectedResource(
|
||||
async () => true,
|
||||
async () => ({ value: "diagnostics" }),
|
||||
);
|
||||
|
||||
assert.deepEqual(decision, {
|
||||
allowed: true,
|
||||
resource: { value: "diagnostics" },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
export type PlatformProtectedResource<T> =
|
||||
| { allowed: true; resource: T }
|
||||
| { allowed: false };
|
||||
|
||||
export async function loadPlatformProtectedResource<T>(
|
||||
validateLiveAccess: () => Promise<boolean>,
|
||||
loadResource: () => Promise<T | null>,
|
||||
): Promise<PlatformProtectedResource<T>> {
|
||||
const liveAccess = await validateLiveAccess().catch(() => false);
|
||||
if (!liveAccess) {
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
const resource = await loadResource();
|
||||
return resource === null
|
||||
? { allowed: false }
|
||||
: { allowed: true, resource };
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { authorizeOrganizationProvisioning } from "./platform-provisioning-access.ts";
|
||||
|
||||
test("non-provisioning identity routes do not require the platform probe", async () => {
|
||||
let probed = false;
|
||||
const decision = await authorizeOrganizationProvisioning(
|
||||
["users"],
|
||||
null,
|
||||
async () => {
|
||||
probed = true;
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(decision, { allowed: true });
|
||||
assert.equal(probed, false);
|
||||
});
|
||||
|
||||
test("organization provisioning requires an authenticated session", async () => {
|
||||
const decision = await authorizeOrganizationProvisioning(
|
||||
["register-company"],
|
||||
null,
|
||||
async () => true,
|
||||
);
|
||||
|
||||
assert.deepEqual(decision, { allowed: false, status: 401, message: "请先登录。" });
|
||||
});
|
||||
|
||||
test("current platform permission and live validation allow provisioning", async () => {
|
||||
const decision = await authorizeOrganizationProvisioning(
|
||||
["register-company"],
|
||||
{ platform_permissions: ["platform_admin"] },
|
||||
async () => true,
|
||||
);
|
||||
|
||||
assert.deepEqual(decision, { allowed: true });
|
||||
});
|
||||
|
||||
test("a newly granted permission is accepted from the authoritative live check", async () => {
|
||||
const decision = await authorizeOrganizationProvisioning(
|
||||
["register-company"],
|
||||
{ platform_permissions: [] },
|
||||
async () => true,
|
||||
);
|
||||
|
||||
assert.deepEqual(decision, { allowed: true });
|
||||
});
|
||||
|
||||
test("a revoked permission is denied by the authoritative live check", async () => {
|
||||
const decision = await authorizeOrganizationProvisioning(
|
||||
["register-company"],
|
||||
{ platform_permissions: ["platform_admin"] },
|
||||
async () => false,
|
||||
);
|
||||
|
||||
assert.equal(decision.allowed, false);
|
||||
assert.equal(decision.status, 403);
|
||||
});
|
||||
|
||||
test("legacy sessions remain compatible only after live validation", async () => {
|
||||
const allowed = await authorizeOrganizationProvisioning(
|
||||
["register-company"],
|
||||
{},
|
||||
async () => true,
|
||||
);
|
||||
const denied = await authorizeOrganizationProvisioning(
|
||||
["register-company"],
|
||||
{},
|
||||
async () => false,
|
||||
);
|
||||
|
||||
assert.deepEqual(allowed, { allowed: true });
|
||||
assert.equal(denied.allowed, false);
|
||||
assert.equal(denied.status, 403);
|
||||
});
|
||||
|
||||
test("backend validation failure is fail-closed", async () => {
|
||||
const decision = await authorizeOrganizationProvisioning(
|
||||
["register-company"],
|
||||
{ platform_permissions: ["platform_admin"] },
|
||||
async () => {
|
||||
throw new Error("backend unavailable");
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(decision.allowed, false);
|
||||
assert.equal(decision.status, 403);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
export type PlatformPermissionSession = {
|
||||
platform_permissions?: readonly string[];
|
||||
};
|
||||
|
||||
export type PlatformProvisioningAccessDecision =
|
||||
| { allowed: true }
|
||||
| { allowed: false; status: 401 | 403; message: string };
|
||||
|
||||
export async function authorizeOrganizationProvisioning(
|
||||
path: string[] | undefined,
|
||||
session: PlatformPermissionSession | null,
|
||||
validateLiveAccess: () => Promise<boolean>,
|
||||
): Promise<PlatformProvisioningAccessDecision> {
|
||||
if (path?.join("/") !== "register-company") {
|
||||
return { allowed: true };
|
||||
}
|
||||
if (!session) {
|
||||
return { allowed: false, status: 401, message: "请先登录。" };
|
||||
}
|
||||
|
||||
const liveAccess = await validateLiveAccess().catch(() => false);
|
||||
if (!liveAccess) {
|
||||
return {
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: "仅平台管理员可以代开组织工作空间。",
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildPrivilegedTargetRefs,
|
||||
hasRequiredPrivilegedTargetRefs,
|
||||
privilegedApprovalProgress,
|
||||
} from "./privileged-identity-operation-state.ts";
|
||||
|
||||
test("target refs are trimmed and empty values are omitted", () => {
|
||||
assert.deepEqual(
|
||||
buildPrivilegedTargetRefs("account_merge", {
|
||||
source_account_id: " account-source ",
|
||||
target_account_id: "account-target",
|
||||
ignored: "value",
|
||||
}),
|
||||
{
|
||||
source_account_id: "account-source",
|
||||
target_account_id: "account-target",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("every operation requires its complete target reference set", () => {
|
||||
assert.equal(hasRequiredPrivilegedTargetRefs("account_merge", {
|
||||
source_account_id: "source",
|
||||
target_account_id: "target",
|
||||
}), true);
|
||||
assert.equal(hasRequiredPrivilegedTargetRefs("account_merge", {
|
||||
source_account_id: "source",
|
||||
}), false);
|
||||
assert.equal(hasRequiredPrivilegedTargetRefs("support_access_grant", {
|
||||
support_account_id: "support",
|
||||
tenant_id: "tenant",
|
||||
permission_scopes: ["contract.read"],
|
||||
expires_at: "2026-08-03T12:00:00Z",
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("structured target refs parse mapping and scope fields", () => {
|
||||
assert.deepEqual(buildPrivilegedTargetRefs("account_split", {
|
||||
source_account_id: "source",
|
||||
destination_account_id: "destination",
|
||||
membership_partition: '{"membership-1":"destination"}',
|
||||
}), {
|
||||
source_account_id: "source",
|
||||
destination_account_id: "destination",
|
||||
membership_partition: { "membership-1": "destination" },
|
||||
});
|
||||
assert.deepEqual(buildPrivilegedTargetRefs("support_access_grant", {
|
||||
support_account_id: "support",
|
||||
tenant_id: "tenant",
|
||||
permission_scopes: "contract.read, audit.read",
|
||||
expires_at: "2026-08-03T12:00:00Z",
|
||||
}).permission_scopes, ["contract.read", "audit.read"]);
|
||||
});
|
||||
|
||||
test("approval progress keeps the two-person default", () => {
|
||||
assert.equal(privilegedApprovalProgress(undefined, undefined), "0/2");
|
||||
assert.equal(privilegedApprovalProgress(1, 2), "1/2");
|
||||
});
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
PrivilegedIdentityOperationStatus,
|
||||
PrivilegedIdentityOperationType,
|
||||
} from "@/services/contracts";
|
||||
|
||||
export type PrivilegedOperationField = {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
format?: "json" | "csv";
|
||||
};
|
||||
|
||||
export const privilegedOperationDefinitions: Record<
|
||||
PrivilegedIdentityOperationType,
|
||||
{ label: string; fields: PrivilegedOperationField[] }
|
||||
> = {
|
||||
account_merge: {
|
||||
label: "账号合并预览",
|
||||
fields: [
|
||||
{ key: "source_account_id", label: "来源账号 ID", placeholder: "identity-account-source" },
|
||||
{ key: "target_account_id", label: "保留账号 ID", placeholder: "identity-account-target" },
|
||||
],
|
||||
},
|
||||
account_split: {
|
||||
label: "账号拆分预览",
|
||||
fields: [
|
||||
{ key: "source_account_id", label: "来源账号 ID", placeholder: "identity-account-source" },
|
||||
{ key: "destination_account_id", label: "目标账号 ID", placeholder: "identity-account-destination" },
|
||||
{
|
||||
key: "membership_partition",
|
||||
label: "成员关系分配",
|
||||
placeholder: '{"membership-id":"identity-account-destination"}',
|
||||
format: "json",
|
||||
},
|
||||
],
|
||||
},
|
||||
membership_recovery: {
|
||||
label: "成员关系恢复预览",
|
||||
fields: [
|
||||
{ key: "account_id", label: "全局账号 ID", placeholder: "identity-account-id" },
|
||||
{ key: "tenant_id", label: "工作空间 ID", placeholder: "tenant-id" },
|
||||
{ key: "tenant_user_id", label: "成员用户 ID", placeholder: "user-id" },
|
||||
{ key: "recovery_type", label: "恢复类型", placeholder: "reactivate / relink / recreate" },
|
||||
],
|
||||
},
|
||||
platform_admin_grant: {
|
||||
label: "平台管理员授予预览",
|
||||
fields: [
|
||||
{ key: "target_tenant_id", label: "目标工作空间 ID", placeholder: "tenant-id" },
|
||||
{ key: "target_user_id", label: "目标用户 ID", placeholder: "user-id" },
|
||||
],
|
||||
},
|
||||
platform_admin_revoke: {
|
||||
label: "平台管理员撤销预览",
|
||||
fields: [
|
||||
{ key: "target_tenant_id", label: "目标工作空间 ID", placeholder: "tenant-id" },
|
||||
{ key: "target_user_id", label: "目标用户 ID", placeholder: "user-id" },
|
||||
],
|
||||
},
|
||||
support_access_grant: {
|
||||
label: "限时支持访问预览",
|
||||
fields: [
|
||||
{ key: "support_account_id", label: "支持账号 ID", placeholder: "identity-account-id" },
|
||||
{ key: "tenant_id", label: "工作空间 ID", placeholder: "tenant-id" },
|
||||
{ key: "permission_scopes", label: "权限范围", placeholder: "contract.read,audit.read", format: "csv" },
|
||||
{ key: "expires_at", label: "访问到期时间", placeholder: "2026-08-03T12:00:00Z" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const privilegedOperationStatusLabels: Record<PrivilegedIdentityOperationStatus, string> = {
|
||||
previewed: "已预览",
|
||||
pending_approval: "待审批",
|
||||
approved: "已批准",
|
||||
rejected: "已拒绝",
|
||||
cancelled: "已取消",
|
||||
expired: "已过期",
|
||||
};
|
||||
|
||||
export function buildPrivilegedTargetRefs(
|
||||
operationType: PrivilegedIdentityOperationType,
|
||||
values: Record<string, string>,
|
||||
): Record<string, unknown> {
|
||||
const refs: Record<string, unknown> = {};
|
||||
for (const field of privilegedOperationDefinitions[operationType].fields) {
|
||||
const value = values[field.key]?.trim() ?? "";
|
||||
if (!value) continue;
|
||||
if (field.format === "csv") {
|
||||
refs[field.key] = value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
} else if (field.format === "json") {
|
||||
try {
|
||||
refs[field.key] = JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
refs[field.key] = null;
|
||||
}
|
||||
} else {
|
||||
refs[field.key] = value;
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
export function hasRequiredPrivilegedTargetRefs(
|
||||
operationType: PrivilegedIdentityOperationType,
|
||||
targetRefs: Record<string, unknown>,
|
||||
): boolean {
|
||||
return privilegedOperationDefinitions[operationType].fields
|
||||
.every((field) => {
|
||||
const value = targetRefs[field.key];
|
||||
if (field.format === "csv") return Array.isArray(value) && value.length > 0;
|
||||
if (field.format === "json") return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
return typeof value === "string" && Boolean(value.trim());
|
||||
});
|
||||
}
|
||||
|
||||
export function privilegedApprovalProgress(
|
||||
approvalCount: number | undefined,
|
||||
requiredApprovals: number | undefined,
|
||||
) {
|
||||
return `${approvalCount ?? 0}/${requiredApprovals ?? 2}`;
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { activeWorkspaceAccess, canSwitchWorkspace, workspaceDisplayName } from "./workspace-access-state.ts";
|
||||
import {
|
||||
activeWorkspaceAccess,
|
||||
canSwitchWorkspace,
|
||||
currentWorkspaceType,
|
||||
workspaceDisplayName,
|
||||
workspaceTypeDescription,
|
||||
workspaceTypeLabel,
|
||||
} from "./workspace-access-state.ts";
|
||||
|
||||
test("activeWorkspaceAccess excludes invited and disabled memberships", () => {
|
||||
const active = activeWorkspaceAccess([
|
||||
@@ -25,3 +32,27 @@ test("workspaceDisplayName maps the legacy default workspace without changing id
|
||||
assert.equal(workspaceDisplayName("tenant-acme", "Acme 法务"), "Acme 法务");
|
||||
assert.equal(workspaceDisplayName("tenant-fallback", " "), "tenant-fallback");
|
||||
});
|
||||
|
||||
test("workspace type labels are explicit and legacy default remains system-owned", () => {
|
||||
assert.equal(workspaceTypeLabel("default", undefined), "系统");
|
||||
assert.equal(workspaceTypeLabel("tenant-personal", "personal"), "个人");
|
||||
assert.equal(workspaceTypeLabel("tenant-acme", "organization"), "组织");
|
||||
assert.equal(workspaceTypeDescription("default", "organization"), "系统工作空间");
|
||||
assert.equal(workspaceTypeDescription("tenant-personal", "personal"), "个人工作空间");
|
||||
assert.equal(workspaceTypeDescription("tenant-acme", undefined), "组织工作空间");
|
||||
});
|
||||
|
||||
test("the live workspace list repairs a legacy session without workspace_type", () => {
|
||||
assert.equal(
|
||||
currentWorkspaceType(
|
||||
[{ workspace: { id: "personal-legacy", workspace_type: "personal" } }],
|
||||
"personal-legacy",
|
||||
undefined,
|
||||
),
|
||||
"personal",
|
||||
);
|
||||
assert.equal(
|
||||
currentWorkspaceType([], "tenant-acme", "organization"),
|
||||
"organization",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,12 @@ export type WorkspaceAccessStateItem = {
|
||||
membership: { status: "active" | "invited" | "disabled" };
|
||||
};
|
||||
|
||||
export type WorkspaceType = "system" | "personal" | "organization";
|
||||
|
||||
export type WorkspaceTypeStateItem = {
|
||||
workspace: { id: string; workspace_type?: WorkspaceType };
|
||||
};
|
||||
|
||||
export function activeWorkspaceAccess<T extends WorkspaceAccessStateItem>(items: T[]): T[] {
|
||||
return items.filter((item) => item.membership.status === "active");
|
||||
}
|
||||
@@ -17,3 +23,32 @@ export function workspaceDisplayName(workspaceId: string, workspaceName: string)
|
||||
}
|
||||
return workspaceName.trim() || workspaceId;
|
||||
}
|
||||
|
||||
export function workspaceTypeLabel(
|
||||
workspaceId: string,
|
||||
workspaceType: WorkspaceType | undefined,
|
||||
) {
|
||||
if (workspaceId === "default" || workspaceType === "system") {
|
||||
return "系统";
|
||||
}
|
||||
if (workspaceType === "personal") {
|
||||
return "个人";
|
||||
}
|
||||
return "组织";
|
||||
}
|
||||
|
||||
export function workspaceTypeDescription(
|
||||
workspaceId: string,
|
||||
workspaceType: WorkspaceType | undefined,
|
||||
) {
|
||||
return `${workspaceTypeLabel(workspaceId, workspaceType)}工作空间`;
|
||||
}
|
||||
|
||||
export function currentWorkspaceType(
|
||||
items: WorkspaceTypeStateItem[],
|
||||
workspaceId: string,
|
||||
sessionWorkspaceType: WorkspaceType | undefined,
|
||||
): WorkspaceType | undefined {
|
||||
return items.find((item) => item.workspace.id === workspaceId)?.workspace.workspace_type
|
||||
?? sessionWorkspaceType;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
if ((pathname === "/admin" || pathname.startsWith("/admin/") || pathname === "/health")
|
||||
&& !canAccessAdminPath(pathname, normalizeUserRole(session.role))) {
|
||||
&& !canAccessAdminPath(pathname, normalizeUserRole(session.role), session.platform_permissions)) {
|
||||
return NextResponse.redirect(new URL("/workspace", request.url));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@ import type {
|
||||
LegalQaRequest,
|
||||
LegalQaResponse,
|
||||
PlatformAdministratorPage,
|
||||
PrivilegedIdentityOperation,
|
||||
PrivilegedIdentityOperationCreateRequest,
|
||||
PrivilegedIdentityOperationEventPage,
|
||||
PrivilegedIdentityOperationPage,
|
||||
PrivilegedIdentityPreview,
|
||||
PrivilegedIdentityPreviewRequest,
|
||||
RolePermissionsPage,
|
||||
TenantPage,
|
||||
TenantUserPage,
|
||||
@@ -52,6 +58,80 @@ export async function getAccountSettings(): Promise<AccountSettings | null> {
|
||||
return fetchClientJson<AccountSettings>("/api/contract-platform/identity/account");
|
||||
}
|
||||
|
||||
export async function getPrivilegedIdentityOperations(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): Promise<PrivilegedIdentityOperationPage | null> {
|
||||
return fetchClientJson<PrivilegedIdentityOperationPage>(
|
||||
`/api/contract-platform/identity/privileged-operations?page=${encodeURIComponent(String(page))}&page_size=${encodeURIComponent(String(pageSize))}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPrivilegedIdentityOperation(
|
||||
operationId: string,
|
||||
): Promise<PrivilegedIdentityOperation | null> {
|
||||
return fetchClientJson<PrivilegedIdentityOperation>(
|
||||
`/api/contract-platform/identity/privileged-operations/${encodeURIComponent(operationId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPrivilegedIdentityOperationEvents(
|
||||
operationId: string,
|
||||
): Promise<PrivilegedIdentityOperationEventPage | null> {
|
||||
return fetchClientJson<PrivilegedIdentityOperationEventPage>(
|
||||
`/api/contract-platform/identity/privileged-operations/${encodeURIComponent(operationId)}/events?page=1&page_size=100`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function previewPrivilegedIdentityOperation(
|
||||
payload: PrivilegedIdentityPreviewRequest,
|
||||
): Promise<PrivilegedIdentityPreview> {
|
||||
return identityClientJsonRequest<PrivilegedIdentityPreview>(
|
||||
"/privileged-operations/preview",
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createPrivilegedIdentityOperation(
|
||||
payload: PrivilegedIdentityOperationCreateRequest,
|
||||
): Promise<PrivilegedIdentityOperation> {
|
||||
return identityClientJsonRequest<PrivilegedIdentityOperation>(
|
||||
"/privileged-operations",
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
export async function decidePrivilegedIdentityOperation(
|
||||
operationId: string,
|
||||
payload: { decision: "approve" | "reject"; comment: string; preview_hash: string },
|
||||
): Promise<PrivilegedIdentityOperation> {
|
||||
return identityClientJsonRequest<PrivilegedIdentityOperation>(
|
||||
`/privileged-operations/${encodeURIComponent(operationId)}/approvals`,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
async function identityClientJsonRequest<T>(pathname: string, payload: unknown): Promise<T> {
|
||||
const response = await fetch(`/api/contract-platform/identity${pathname}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => null) as {
|
||||
detail?: string | { message?: string };
|
||||
message?: string;
|
||||
} | null;
|
||||
const detail = errorPayload?.detail;
|
||||
throw new Error(
|
||||
(typeof detail === "string" ? detail : detail?.message)
|
||||
?? errorPayload?.message
|
||||
?? "Identity governance request failed.",
|
||||
);
|
||||
}
|
||||
return await response.json() as T;
|
||||
}
|
||||
|
||||
async function fetchClientJson<T>(pathname: string): Promise<T | null> {
|
||||
try {
|
||||
const response = await fetch(pathname, { cache: "no-store" });
|
||||
|
||||
@@ -364,6 +364,7 @@ export type RolePermissionsPage = {
|
||||
export type TenantInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
workspace_type: "system" | "personal" | "organization";
|
||||
status: "active" | "suspended" | "archived";
|
||||
plan?: string | null;
|
||||
region?: string | null;
|
||||
@@ -419,6 +420,140 @@ export type PlatformAdministratorPage = {
|
||||
active_total: number;
|
||||
};
|
||||
|
||||
export type PrivilegedIdentityOperationType =
|
||||
| "account_merge"
|
||||
| "account_split"
|
||||
| "membership_recovery"
|
||||
| "platform_admin_grant"
|
||||
| "platform_admin_revoke"
|
||||
| "support_access_grant";
|
||||
|
||||
export type PrivilegedIdentityOperationStatus =
|
||||
| "previewed"
|
||||
| "pending_approval"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "cancelled"
|
||||
| "expired";
|
||||
|
||||
export type PrivilegedIdentityEvidenceRef = {
|
||||
type: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type PrivilegedIdentityExpectedRevision = {
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
revision: string;
|
||||
};
|
||||
|
||||
export type PrivilegedIdentityPreviewRequest = {
|
||||
operation_type: PrivilegedIdentityOperationType;
|
||||
target_refs: Record<string, unknown>;
|
||||
evidence_refs?: PrivilegedIdentityEvidenceRef[];
|
||||
};
|
||||
|
||||
export type PrivilegedIdentityPreview = {
|
||||
preview_id: string;
|
||||
operation_type: PrivilegedIdentityOperationType;
|
||||
preview_hash: string;
|
||||
expires_at: string;
|
||||
target_refs: Record<string, unknown>;
|
||||
evidence_refs?: PrivilegedIdentityEvidenceRef[];
|
||||
expected_revisions: PrivilegedIdentityExpectedRevision[];
|
||||
impact_summary: Record<string, number>;
|
||||
blocking_issues: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type PrivilegedIdentityApproval = {
|
||||
approver_account_id: string;
|
||||
decision: "approve" | "reject";
|
||||
comment?: string | null;
|
||||
preview_hash: string;
|
||||
decided_at: string;
|
||||
expires_at?: string | null;
|
||||
};
|
||||
|
||||
export type PrivilegedIdentityOperationEventType =
|
||||
| "operation_submitted"
|
||||
| "approval_recorded"
|
||||
| "operation_rejected"
|
||||
| "operation_expired";
|
||||
|
||||
export type PrivilegedIdentityOperationEventSnapshot = {
|
||||
exists: boolean;
|
||||
operation_type?: PrivilegedIdentityOperationType;
|
||||
status?: PrivilegedIdentityOperationStatus;
|
||||
approval_count?: number;
|
||||
target_reference_count?: number;
|
||||
target_reference_fingerprints?: string[];
|
||||
evidence_reference_count?: number;
|
||||
warning_count?: number;
|
||||
};
|
||||
|
||||
export type PrivilegedIdentityOperationEvent = {
|
||||
event_id: string;
|
||||
operation_id: string;
|
||||
sequence_no: number;
|
||||
event_type: PrivilegedIdentityOperationEventType;
|
||||
status_before?: PrivilegedIdentityOperationStatus | null;
|
||||
status_after: PrivilegedIdentityOperationStatus;
|
||||
trace_id?: string | null;
|
||||
actor_account_id?: string | null;
|
||||
actor_tenant_id?: string | null;
|
||||
actor_user_id?: string | null;
|
||||
snapshot_before: PrivilegedIdentityOperationEventSnapshot;
|
||||
snapshot_after: PrivilegedIdentityOperationEventSnapshot;
|
||||
snapshot_before_hash: string;
|
||||
snapshot_after_hash: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type PrivilegedIdentityOperationEventPage = {
|
||||
items: PrivilegedIdentityOperationEvent[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
|
||||
export type PrivilegedIdentityOperation = {
|
||||
operation_id: string;
|
||||
operation_type: PrivilegedIdentityOperationType;
|
||||
status: PrivilegedIdentityOperationStatus;
|
||||
requested_by_account_id: string;
|
||||
reason: string;
|
||||
ticket_reference: string;
|
||||
target_refs: Record<string, unknown>;
|
||||
evidence_refs: PrivilegedIdentityEvidenceRef[];
|
||||
preview_hash: string;
|
||||
expected_revisions: PrivilegedIdentityExpectedRevision[];
|
||||
approvals: PrivilegedIdentityApproval[];
|
||||
approval_count?: number;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type PrivilegedIdentityOperationPage = {
|
||||
items: PrivilegedIdentityOperation[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
|
||||
export type PrivilegedIdentityOperationCreateRequest = {
|
||||
operation_id: string;
|
||||
preview_hash: string;
|
||||
preview_expires_at: string;
|
||||
reason: string;
|
||||
ticket_reference: string;
|
||||
requested_expires_at?: string;
|
||||
operation_type: PrivilegedIdentityOperationType;
|
||||
target_refs: Record<string, unknown>;
|
||||
evidence_refs: PrivilegedIdentityEvidenceRef[];
|
||||
};
|
||||
|
||||
export type AccountConflictSeverity = "critical" | "warning";
|
||||
|
||||
export type AccountConflictCode =
|
||||
@@ -1502,6 +1637,58 @@ export async function getAccountConflict(
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPrivilegedIdentityOperations(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): Promise<PrivilegedIdentityOperationPage | null> {
|
||||
const suffix = `?page=${encodeURIComponent(String(page))}&page_size=${encodeURIComponent(String(pageSize))}`;
|
||||
return fetchJson<PrivilegedIdentityOperationPage>(
|
||||
`/api/v1/identity/privileged-operations${suffix}`,
|
||||
`/api/contract-platform/identity/privileged-operations${suffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPrivilegedIdentityOperation(
|
||||
operationId: string,
|
||||
): Promise<PrivilegedIdentityOperation | null> {
|
||||
const encodedId = encodeURIComponent(operationId);
|
||||
return fetchJson<PrivilegedIdentityOperation>(
|
||||
`/api/v1/identity/privileged-operations/${encodedId}`,
|
||||
`/api/contract-platform/identity/privileged-operations/${encodedId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function previewPrivilegedIdentityOperation(
|
||||
payload: PrivilegedIdentityPreviewRequest,
|
||||
): Promise<PrivilegedIdentityPreview> {
|
||||
return identityJsonRequest<PrivilegedIdentityPreview>(
|
||||
"/privileged-operations/preview",
|
||||
"POST",
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createPrivilegedIdentityOperation(
|
||||
payload: PrivilegedIdentityOperationCreateRequest,
|
||||
): Promise<PrivilegedIdentityOperation> {
|
||||
return identityJsonRequest<PrivilegedIdentityOperation>(
|
||||
"/privileged-operations",
|
||||
"POST",
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
export async function decidePrivilegedIdentityOperation(
|
||||
operationId: string,
|
||||
payload: { decision: "approve" | "reject"; comment: string; preview_hash: string },
|
||||
): Promise<PrivilegedIdentityOperation> {
|
||||
return identityJsonRequest<PrivilegedIdentityOperation>(
|
||||
`/privileged-operations/${encodeURIComponent(operationId)}/approvals`,
|
||||
"POST",
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getIdentityRoles(): Promise<RolePermissionsPage> {
|
||||
const payload = await fetchJson<RolePermissionsPage>(
|
||||
"/api/v1/identity/roles",
|
||||
@@ -1576,6 +1763,29 @@ async function fetchJson<T>(
|
||||
}
|
||||
}
|
||||
|
||||
async function identityJsonRequest<T>(
|
||||
pathname: string,
|
||||
method: "POST",
|
||||
payload: unknown,
|
||||
): Promise<T> {
|
||||
const response = await fetch(`/api/contract-platform/identity${pathname}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => null) as {
|
||||
detail?: string | { code?: string; message?: string };
|
||||
message?: string;
|
||||
} | null;
|
||||
const detail = typeof errorPayload?.detail === "string"
|
||||
? errorPayload.detail
|
||||
: errorPayload?.detail?.message;
|
||||
throw new Error(detail || errorPayload?.message || `身份治理请求失败(${response.status})。`);
|
||||
}
|
||||
return await response.json() as T;
|
||||
}
|
||||
|
||||
async function fetchAiJson<T>(
|
||||
apiPathname: string,
|
||||
proxyPathname: string,
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
"""add privileged identity operation governance tables
|
||||
|
||||
Revision ID: 20260802_0037
|
||||
Revises: 20260802_0036
|
||||
Create Date: 2026-08-02
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260802_0037"
|
||||
down_revision: Union[str, None] = "20260802_0036"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"privileged_identity_operations",
|
||||
sa.Column("operation_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("operation_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False),
|
||||
sa.Column("request_payload_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("requested_by_account_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("requested_by_tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("requested_by_user_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column("ticket_reference", sa.String(length=128), nullable=False),
|
||||
sa.Column("target_refs", sa.JSON(), nullable=False),
|
||||
sa.Column("evidence_refs", sa.JSON(), nullable=False),
|
||||
sa.Column("preview_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("expected_revisions", sa.JSON(), nullable=False),
|
||||
sa.Column("impact_summary", sa.JSON(), nullable=False),
|
||||
sa.Column("warnings", sa.JSON(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.CheckConstraint(
|
||||
"operation_type in ('account_merge', 'account_split', 'membership_recovery', "
|
||||
"'platform_admin_grant', 'platform_admin_revoke', 'support_access_grant')",
|
||||
name="ck_privileged_identity_operations_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status in ('pending_approval', 'approved', 'rejected', 'cancelled', 'expired')",
|
||||
name="ck_privileged_identity_operations_status",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["requested_by_account_id"], ["identity_accounts.id"]),
|
||||
sa.PrimaryKeyConstraint("operation_id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_privileged_identity_operations_status_created",
|
||||
"privileged_identity_operations",
|
||||
["status", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_privileged_identity_operations_requester_created",
|
||||
"privileged_identity_operations",
|
||||
["requested_by_account_id", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_privileged_identity_operations_type_created",
|
||||
"privileged_identity_operations",
|
||||
["operation_type", "created_at"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"privileged_identity_operation_approvals",
|
||||
sa.Column("id", sa.String(length=64), nullable=False),
|
||||
sa.Column("operation_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("approver_account_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("approver_tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("approver_user_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("decision", sa.String(length=16), nullable=False),
|
||||
sa.Column("comment", sa.Text(), nullable=False),
|
||||
sa.Column("preview_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("permission_revision", sa.String(length=64), nullable=False),
|
||||
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.CheckConstraint(
|
||||
"decision in ('approve', 'reject')",
|
||||
name="ck_privileged_identity_operation_approvals_decision",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["operation_id"],
|
||||
["privileged_identity_operations.operation_id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["approver_account_id"], ["identity_accounts.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"operation_id",
|
||||
"approver_account_id",
|
||||
name="ux_privileged_identity_approvals_operation_account",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_privileged_identity_approvals_operation_decided",
|
||||
"privileged_identity_operation_approvals",
|
||||
["operation_id", "decided_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_privileged_identity_approvals_operation_decided",
|
||||
table_name="privileged_identity_operation_approvals",
|
||||
)
|
||||
op.drop_table("privileged_identity_operation_approvals")
|
||||
op.drop_index(
|
||||
"ix_privileged_identity_operations_type_created",
|
||||
table_name="privileged_identity_operations",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_privileged_identity_operations_requester_created",
|
||||
table_name="privileged_identity_operations",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_privileged_identity_operations_status_created",
|
||||
table_name="privileged_identity_operations",
|
||||
)
|
||||
op.drop_table("privileged_identity_operations")
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
"""add append-only privileged identity operation events
|
||||
|
||||
Revision ID: 20260802_0038
|
||||
Revises: 20260802_0037
|
||||
Create Date: 2026-08-02
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260802_0038"
|
||||
down_revision: Union[str, None] = "20260802_0037"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"privileged_identity_operation_events",
|
||||
sa.Column("event_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("operation_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("trace_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("actor_account_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("actor_tenant_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("actor_user_id", sa.String(length=64), 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("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.CheckConstraint(
|
||||
"event_type in ('operation_submitted', 'approval_recorded', "
|
||||
"'operation_rejected', 'operation_expired')",
|
||||
name="ck_privileged_identity_operation_events_type",
|
||||
),
|
||||
sa.CheckConstraint("sequence_no > 0", name="ck_privileged_identity_operation_events_sequence"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["operation_id"],
|
||||
["privileged_identity_operations.operation_id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(["actor_account_id"], ["identity_accounts.id"]),
|
||||
sa.PrimaryKeyConstraint("event_id"),
|
||||
sa.UniqueConstraint(
|
||||
"operation_id",
|
||||
"sequence_no",
|
||||
name="ux_privileged_identity_operation_events_operation_sequence",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_privileged_identity_operation_events_operation_created",
|
||||
"privileged_identity_operation_events",
|
||||
["operation_id", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_privileged_identity_operation_events_type_created",
|
||||
"privileged_identity_operation_events",
|
||||
["event_type", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_privileged_identity_operation_events_trace",
|
||||
"privileged_identity_operation_events",
|
||||
["trace_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_privileged_identity_operation_events_trace",
|
||||
table_name="privileged_identity_operation_events",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_privileged_identity_operation_events_type_created",
|
||||
table_name="privileged_identity_operation_events",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_privileged_identity_operation_events_operation_created",
|
||||
table_name="privileged_identity_operation_events",
|
||||
)
|
||||
op.drop_table("privileged_identity_operation_events")
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
"""enforce privileged identity event immutability
|
||||
|
||||
Revision ID: 20260802_0039
|
||||
Revises: 20260802_0038
|
||||
Create Date: 2026-08-02
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260802_0039"
|
||||
down_revision: Union[str, None] = "20260802_0038"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "sqlite":
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE TRIGGER trg_privileged_identity_operation_events_no_update
|
||||
BEFORE UPDATE ON privileged_identity_operation_events
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'privileged identity operation events are append-only');
|
||||
END
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE TRIGGER trg_privileged_identity_operation_events_no_delete
|
||||
BEFORE DELETE ON privileged_identity_operation_events
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'privileged identity operation events are append-only');
|
||||
END
|
||||
"""
|
||||
)
|
||||
)
|
||||
elif bind.dialect.name == "postgresql":
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION privileged_identity_operation_events_immutable()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'privileged identity operation events are append-only';
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE TRIGGER trg_privileged_identity_operation_events_immutable
|
||||
BEFORE UPDATE OR DELETE ON privileged_identity_operation_events
|
||||
FOR EACH ROW EXECUTE FUNCTION privileged_identity_operation_events_immutable()
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "sqlite":
|
||||
op.execute(
|
||||
sa.text(
|
||||
"DROP TRIGGER IF EXISTS trg_privileged_identity_operation_events_no_update"
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"DROP TRIGGER IF EXISTS trg_privileged_identity_operation_events_no_delete"
|
||||
)
|
||||
)
|
||||
elif bind.dialect.name == "postgresql":
|
||||
op.execute(
|
||||
sa.text(
|
||||
"DROP TRIGGER IF EXISTS trg_privileged_identity_operation_events_immutable "
|
||||
"ON privileged_identity_operation_events"
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"DROP FUNCTION IF EXISTS privileged_identity_operation_events_immutable()"
|
||||
)
|
||||
)
|
||||
@@ -588,6 +588,39 @@ def contract_endpoint_risk_policies(api_prefix: str) -> tuple[EndpointRiskPolicy
|
||||
"records",
|
||||
True,
|
||||
),
|
||||
EndpointRiskPolicy(
|
||||
"identity.privileged_operations.preview",
|
||||
"POST",
|
||||
f"{p}/identity/privileged-operations/preview",
|
||||
"S3",
|
||||
10,
|
||||
40,
|
||||
20,
|
||||
"records",
|
||||
True,
|
||||
),
|
||||
EndpointRiskPolicy(
|
||||
"identity.privileged_operations.write",
|
||||
"POST",
|
||||
f"{p}/identity/privileged-operations",
|
||||
"S3",
|
||||
10,
|
||||
40,
|
||||
20,
|
||||
"records",
|
||||
True,
|
||||
),
|
||||
EndpointRiskPolicy(
|
||||
"identity.privileged_operations.read",
|
||||
"GET",
|
||||
f"{p}/identity/privileged-operations",
|
||||
"S3",
|
||||
20,
|
||||
100,
|
||||
40,
|
||||
"records",
|
||||
True,
|
||||
),
|
||||
EndpointRiskPolicy("audit", "GET", f"{p}/audit", "S2", 20, 200, 60, "records", True),
|
||||
)
|
||||
|
||||
|
||||
+180
-1
@@ -6,7 +6,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from ipaddress import ip_address
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request, Response
|
||||
|
||||
from yuqei_contract_api.config import ContractApiSettings
|
||||
from yuqei_contract_api.domain import RolePermissionsPage
|
||||
@@ -61,6 +61,18 @@ from yuqei_contract_api.identity import (
|
||||
current_registration_agreements,
|
||||
)
|
||||
from yuqei_contract_api.persistence.models import DEFAULT_TENANT_ID
|
||||
from yuqei_contract_api.privileged_identity import (
|
||||
PrivilegedIdentityOperationApprovalRequest,
|
||||
PrivilegedIdentityOperationError,
|
||||
PrivilegedIdentityOperationEventPage,
|
||||
PrivilegedIdentityOperationItem,
|
||||
PrivilegedIdentityOperationPage,
|
||||
PrivilegedIdentityOperationPreview,
|
||||
PrivilegedIdentityOperationPreviewRequest,
|
||||
PrivilegedIdentityOperationStatus,
|
||||
PrivilegedIdentityOperationSubmitRequest,
|
||||
PrivilegedIdentityOperationType,
|
||||
)
|
||||
from yuqei_contract_api.logging import get_logger
|
||||
from yuqei_contract_api.repository_base import ContractRepository
|
||||
from yuqei_contract_api.tenant_context import get_tenant_context, get_user_context
|
||||
@@ -163,6 +175,138 @@ def register_identity_routes(
|
||||
raise HTTPException(status_code=404, detail="Account conflict diagnostic not found.")
|
||||
return item
|
||||
|
||||
@app.post(
|
||||
f"{settings.api_prefix}/identity/privileged-operations/preview",
|
||||
response_model=PrivilegedIdentityOperationPreview,
|
||||
tags=["identity"],
|
||||
)
|
||||
def preview_privileged_identity_operation(
|
||||
request: PrivilegedIdentityOperationPreviewRequest,
|
||||
) -> PrivilegedIdentityOperationPreview:
|
||||
_require_privileged_platform_admin(settings, identity_repository)
|
||||
privileged_repository = _privileged_operation_repository(identity_repository)
|
||||
try:
|
||||
return privileged_repository.preview(request)
|
||||
except PrivilegedIdentityOperationError as exc:
|
||||
raise _privileged_operation_http_error(exc) from exc
|
||||
|
||||
@app.post(
|
||||
f"{settings.api_prefix}/identity/privileged-operations",
|
||||
response_model=PrivilegedIdentityOperationItem,
|
||||
tags=["identity"],
|
||||
)
|
||||
def submit_privileged_identity_operation(
|
||||
request: PrivilegedIdentityOperationSubmitRequest,
|
||||
response: Response,
|
||||
) -> PrivilegedIdentityOperationItem:
|
||||
_require_privileged_platform_admin(settings, identity_repository)
|
||||
privileged_repository = _privileged_operation_repository(identity_repository)
|
||||
try:
|
||||
result = privileged_repository.submit(
|
||||
request,
|
||||
actor_tenant_id=_current_tenant_id(),
|
||||
actor_user_id=_current_user_id(),
|
||||
)
|
||||
response.status_code = 201 if result.created else 200
|
||||
return result.item
|
||||
except PrivilegedIdentityOperationError as exc:
|
||||
raise _privileged_operation_http_error(exc) from exc
|
||||
|
||||
@app.get(
|
||||
f"{settings.api_prefix}/identity/privileged-operations",
|
||||
response_model=PrivilegedIdentityOperationPage,
|
||||
tags=["identity"],
|
||||
)
|
||||
def list_privileged_identity_operations(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
status: PrivilegedIdentityOperationStatus | None = None,
|
||||
operation_type: PrivilegedIdentityOperationType | None = None,
|
||||
) -> PrivilegedIdentityOperationPage:
|
||||
_require_privileged_platform_admin(settings, identity_repository)
|
||||
privileged_repository = _privileged_operation_repository(identity_repository)
|
||||
try:
|
||||
return privileged_repository.list_operations(
|
||||
actor_tenant_id=_current_tenant_id(),
|
||||
actor_user_id=_current_user_id(),
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
status=status,
|
||||
operation_type=operation_type,
|
||||
)
|
||||
except PrivilegedIdentityOperationError as exc:
|
||||
raise _privileged_operation_http_error(exc) from exc
|
||||
|
||||
@app.get(
|
||||
f"{settings.api_prefix}/identity/privileged-operations/{{operation_id}}",
|
||||
response_model=PrivilegedIdentityOperationItem,
|
||||
tags=["identity"],
|
||||
)
|
||||
def get_privileged_identity_operation(operation_id: str) -> PrivilegedIdentityOperationItem:
|
||||
_require_privileged_platform_admin(settings, identity_repository)
|
||||
privileged_repository = _privileged_operation_repository(identity_repository)
|
||||
try:
|
||||
item = privileged_repository.get_operation(
|
||||
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
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"code": "operation_not_found",
|
||||
"message": "Privileged identity operation not found.",
|
||||
},
|
||||
)
|
||||
return item
|
||||
|
||||
@app.get(
|
||||
f"{settings.api_prefix}/identity/privileged-operations/{{operation_id}}/events",
|
||||
response_model=PrivilegedIdentityOperationEventPage,
|
||||
tags=["identity"],
|
||||
)
|
||||
def list_privileged_identity_operation_events(
|
||||
operation_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=100),
|
||||
) -> PrivilegedIdentityOperationEventPage:
|
||||
_require_privileged_platform_admin(settings, identity_repository)
|
||||
privileged_repository = _privileged_operation_repository(identity_repository)
|
||||
try:
|
||||
return privileged_repository.list_events(
|
||||
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.post(
|
||||
f"{settings.api_prefix}/identity/privileged-operations/{{operation_id}}/approvals",
|
||||
response_model=PrivilegedIdentityOperationItem,
|
||||
tags=["identity"],
|
||||
)
|
||||
def decide_privileged_identity_operation(
|
||||
operation_id: str,
|
||||
request: PrivilegedIdentityOperationApprovalRequest,
|
||||
) -> PrivilegedIdentityOperationItem:
|
||||
_require_privileged_platform_admin(settings, identity_repository)
|
||||
privileged_repository = _privileged_operation_repository(identity_repository)
|
||||
try:
|
||||
return privileged_repository.decide(
|
||||
operation_id,
|
||||
request,
|
||||
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.post(
|
||||
f"{settings.api_prefix}/identity/tenants",
|
||||
response_model=TenantInfo,
|
||||
@@ -706,6 +850,41 @@ def _require_platform_admin(settings: ContractApiSettings, identity_repository)
|
||||
raise HTTPException(status_code=403, detail="Platform administrator access is required.")
|
||||
|
||||
|
||||
def _require_privileged_platform_admin(settings: ContractApiSettings, identity_repository) -> None:
|
||||
try:
|
||||
_require_platform_admin(settings, identity_repository)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code != 403:
|
||||
raise
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"code": "platform_admin_required",
|
||||
"message": "Platform administrator access is required.",
|
||||
},
|
||||
) from exc
|
||||
|
||||
|
||||
def _privileged_operation_repository(identity_repository):
|
||||
repository = getattr(identity_repository, "privileged_operations", None)
|
||||
if repository is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"code": "persistent_governance_unavailable",
|
||||
"message": "Privileged identity operation governance requires persistent storage.",
|
||||
},
|
||||
)
|
||||
return repository
|
||||
|
||||
|
||||
def _privileged_operation_http_error(exc: PrivilegedIdentityOperationError) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=exc.status_code,
|
||||
detail={"code": exc.code, "message": exc.message},
|
||||
)
|
||||
|
||||
|
||||
def _require_self_service_or_platform_admin(settings: ContractApiSettings, identity_repository) -> None:
|
||||
if settings.identity_self_service_enabled:
|
||||
return
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
|
||||
_EVENT_TABLE = "privileged_identity_operation_events"
|
||||
_SQLITE_UPDATE_TRIGGER = "trg_privileged_identity_operation_events_no_update"
|
||||
_SQLITE_DELETE_TRIGGER = "trg_privileged_identity_operation_events_no_delete"
|
||||
_POSTGRES_TRIGGER = "trg_privileged_identity_operation_events_immutable"
|
||||
_POSTGRES_FUNCTION = "privileged_identity_operation_events_immutable"
|
||||
|
||||
|
||||
def install_privileged_identity_operation_event_guards(engine: Engine) -> None:
|
||||
"""Prevent direct mutation of append-only governance events on auto-created databases."""
|
||||
dialect = engine.dialect.name
|
||||
if dialect == "sqlite":
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
f"""
|
||||
CREATE TRIGGER IF NOT EXISTS {_SQLITE_UPDATE_TRIGGER}
|
||||
BEFORE UPDATE ON {_EVENT_TABLE}
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'privileged identity operation events are append-only');
|
||||
END
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
f"""
|
||||
CREATE TRIGGER IF NOT EXISTS {_SQLITE_DELETE_TRIGGER}
|
||||
BEFORE DELETE ON {_EVENT_TABLE}
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'privileged identity operation events are append-only');
|
||||
END
|
||||
"""
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if dialect == "postgresql":
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
f"""
|
||||
CREATE OR REPLACE FUNCTION {_POSTGRES_FUNCTION}()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'privileged identity operation events are append-only';
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
f"DROP TRIGGER IF EXISTS {_POSTGRES_TRIGGER} "
|
||||
f"ON {_EVENT_TABLE}"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
f"CREATE TRIGGER {_POSTGRES_TRIGGER} "
|
||||
f"BEFORE UPDATE OR DELETE ON {_EVENT_TABLE} "
|
||||
f"FOR EACH ROW EXECUTE FUNCTION {_POSTGRES_FUNCTION}()"
|
||||
)
|
||||
)
|
||||
+2
@@ -97,11 +97,13 @@ from yuqei_contract_api.persistence.models import (
|
||||
TenantUserModel,
|
||||
WorkspaceMembershipModel,
|
||||
)
|
||||
from yuqei_contract_api.persistence.privileged_identity import PrivilegedIdentityOperationRepository
|
||||
|
||||
|
||||
class IdentityRepository:
|
||||
def __init__(self, session_factory: sessionmaker) -> None:
|
||||
self._session_factory = session_factory
|
||||
self.privileged_operations = PrivilegedIdentityOperationRepository(session_factory)
|
||||
|
||||
def seed_defaults(self) -> None:
|
||||
self._ensure_local_workspace_schema()
|
||||
|
||||
+115
@@ -202,6 +202,121 @@ class WorkspaceMembershipModel(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
|
||||
|
||||
class PrivilegedIdentityOperationModel(Base):
|
||||
__tablename__ = "privileged_identity_operations"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"operation_type in ('account_merge', 'account_split', 'membership_recovery', "
|
||||
"'platform_admin_grant', 'platform_admin_revoke', 'support_access_grant')",
|
||||
name="ck_privileged_identity_operations_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status in ('pending_approval', 'approved', 'rejected', 'cancelled', 'expired')",
|
||||
name="ck_privileged_identity_operations_status",
|
||||
),
|
||||
Index("ix_privileged_identity_operations_status_created", "status", "created_at"),
|
||||
Index("ix_privileged_identity_operations_requester_created", "requested_by_account_id", "created_at"),
|
||||
Index("ix_privileged_identity_operations_type_created", "operation_type", "created_at"),
|
||||
)
|
||||
|
||||
operation_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
operation_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending_approval")
|
||||
request_payload_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
requested_by_account_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("identity_accounts.id"), nullable=False, index=False
|
||||
)
|
||||
requested_by_tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
requested_by_user_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
ticket_reference: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
target_refs: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
evidence_refs: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
||||
preview_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
class PrivilegedIdentityOperationApprovalModel(Base):
|
||||
__tablename__ = "privileged_identity_operation_approvals"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"decision in ('approve', 'reject')",
|
||||
name="ck_privileged_identity_operation_approvals_decision",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"operation_id",
|
||||
"approver_account_id",
|
||||
name="ux_privileged_identity_approvals_operation_account",
|
||||
),
|
||||
Index("ix_privileged_identity_approvals_operation_decided", "operation_id", "decided_at"),
|
||||
)
|
||||
|
||||
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="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
approver_account_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("identity_accounts.id"), nullable=False
|
||||
)
|
||||
approver_tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
approver_user_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
decision: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
comment: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
preview_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
permission_revision: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
decided_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
|
||||
class PrivilegedIdentityOperationEventModel(Base):
|
||||
__tablename__ = "privileged_identity_operation_events"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"event_type in ('operation_submitted', 'approval_recorded', "
|
||||
"'operation_rejected', 'operation_expired')",
|
||||
name="ck_privileged_identity_operation_events_type",
|
||||
),
|
||||
CheckConstraint("sequence_no > 0", name="ck_privileged_identity_operation_events_sequence"),
|
||||
UniqueConstraint(
|
||||
"operation_id",
|
||||
"sequence_no",
|
||||
name="ux_privileged_identity_operation_events_operation_sequence",
|
||||
),
|
||||
Index("ix_privileged_identity_operation_events_operation_created", "operation_id", "created_at"),
|
||||
Index("ix_privileged_identity_operation_events_type_created", "event_type", "created_at"),
|
||||
Index("ix_privileged_identity_operation_events_trace", "trace_id"),
|
||||
)
|
||||
|
||||
event_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
operation_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("privileged_identity_operations.operation_id"),
|
||||
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)
|
||||
trace_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
actor_account_id: Mapped[str | None] = mapped_column(
|
||||
String(64), ForeignKey("identity_accounts.id"), nullable=True
|
||||
)
|
||||
actor_tenant_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(String(64), 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)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
|
||||
class TenantInvitationModel(GovernanceMixin, Base):
|
||||
__tablename__ = "tenant_invitations"
|
||||
__table_args__ = (
|
||||
|
||||
+1285
File diff suppressed because it is too large
Load Diff
+194
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from hashlib import sha256
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
PrivilegedIdentityOperationType = Literal[
|
||||
"account_merge",
|
||||
"account_split",
|
||||
"membership_recovery",
|
||||
"platform_admin_grant",
|
||||
"platform_admin_revoke",
|
||||
"support_access_grant",
|
||||
]
|
||||
PrivilegedIdentityOperationStatus = Literal[
|
||||
"pending_approval",
|
||||
"approved",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
"expired",
|
||||
]
|
||||
PrivilegedIdentityApprovalDecision = Literal["approve", "reject"]
|
||||
PrivilegedIdentityOperationEventType = Literal[
|
||||
"operation_submitted",
|
||||
"approval_recorded",
|
||||
"operation_rejected",
|
||||
"operation_expired",
|
||||
]
|
||||
|
||||
|
||||
class PrivilegedIdentityOperationError(ValueError):
|
||||
def __init__(self, *, status_code: int, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
class PrivilegedIdentityEvidenceRef(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: str = Field(min_length=1, max_length=64)
|
||||
id: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class PrivilegedIdentityExpectedRevision(BaseModel):
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
revision: str
|
||||
|
||||
|
||||
class PrivilegedIdentityOperationPreviewRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
operation_type: PrivilegedIdentityOperationType
|
||||
target_refs: dict[str, object]
|
||||
evidence_refs: list[PrivilegedIdentityEvidenceRef] = Field(default_factory=list, max_length=20)
|
||||
|
||||
|
||||
class PrivilegedIdentityOperationPreview(BaseModel):
|
||||
preview_id: str
|
||||
operation_type: PrivilegedIdentityOperationType
|
||||
target_refs: dict[str, object]
|
||||
evidence_refs: list[PrivilegedIdentityEvidenceRef]
|
||||
preview_hash: str
|
||||
expected_revisions: list[PrivilegedIdentityExpectedRevision]
|
||||
impact_summary: dict[str, int]
|
||||
blocking_issues: list[str] = Field(default_factory=list)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class PrivilegedIdentityOperationSubmitRequest(PrivilegedIdentityOperationPreviewRequest):
|
||||
operation_id: str = Field(
|
||||
min_length=8,
|
||||
max_length=64,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]+$",
|
||||
)
|
||||
preview_hash: str = Field(min_length=16, max_length=128)
|
||||
preview_expires_at: datetime
|
||||
reason: str = Field(min_length=8, max_length=1000)
|
||||
ticket_reference: str = Field(min_length=3, max_length=128)
|
||||
requested_expires_at: datetime | None = None
|
||||
|
||||
|
||||
class PrivilegedIdentityOperationApprovalRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
decision: PrivilegedIdentityApprovalDecision
|
||||
comment: str = Field(min_length=3, max_length=1000)
|
||||
preview_hash: str = Field(min_length=16, max_length=128)
|
||||
|
||||
|
||||
class PrivilegedIdentityOperationApprovalItem(BaseModel):
|
||||
approver_account_id: str
|
||||
decision: PrivilegedIdentityApprovalDecision
|
||||
comment: str
|
||||
preview_hash: str
|
||||
decided_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class PrivilegedIdentityOperationItem(BaseModel):
|
||||
operation_id: str
|
||||
operation_type: PrivilegedIdentityOperationType
|
||||
status: PrivilegedIdentityOperationStatus
|
||||
requested_by_account_id: str
|
||||
reason: str
|
||||
ticket_reference: str
|
||||
target_refs: dict[str, object]
|
||||
evidence_refs: list[PrivilegedIdentityEvidenceRef]
|
||||
preview_hash: str
|
||||
expected_revisions: list[PrivilegedIdentityExpectedRevision]
|
||||
impact_summary: dict[str, int]
|
||||
warnings: list[str]
|
||||
approvals: list[PrivilegedIdentityOperationApprovalItem]
|
||||
approval_count: int
|
||||
expires_at: datetime
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PrivilegedIdentityOperationPage(BaseModel):
|
||||
items: list[PrivilegedIdentityOperationItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class PrivilegedIdentityOperationEventItem(BaseModel):
|
||||
event_id: str
|
||||
operation_id: str
|
||||
sequence_no: int
|
||||
event_type: PrivilegedIdentityOperationEventType
|
||||
status_before: PrivilegedIdentityOperationStatus | None = None
|
||||
status_after: PrivilegedIdentityOperationStatus
|
||||
trace_id: str | None = None
|
||||
actor_account_id: str | None = None
|
||||
actor_tenant_id: str | None = None
|
||||
actor_user_id: str | None = None
|
||||
snapshot_before: dict[str, object]
|
||||
snapshot_after: dict[str, object]
|
||||
snapshot_before_hash: str
|
||||
snapshot_after_hash: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PrivilegedIdentityOperationEventPage(BaseModel):
|
||||
items: list[PrivilegedIdentityOperationEventItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
def normalize_privileged_json(value: object) -> object:
|
||||
if value is None or isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return [normalize_privileged_json(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
if not all(isinstance(key, str) for key in value):
|
||||
raise PrivilegedIdentityOperationError(
|
||||
status_code=400,
|
||||
code="invalid_target_refs",
|
||||
message="Target reference keys must be strings.",
|
||||
)
|
||||
return {
|
||||
key.strip(): normalize_privileged_json(item)
|
||||
for key, item in sorted(value.items())
|
||||
}
|
||||
raise PrivilegedIdentityOperationError(
|
||||
status_code=400,
|
||||
code="invalid_target_refs",
|
||||
message="Target references must contain only JSON values.",
|
||||
)
|
||||
|
||||
|
||||
def privileged_payload_hash(value: object) -> str:
|
||||
normalized = normalize_privileged_json(value)
|
||||
payload = json.dumps(
|
||||
normalized,
|
||||
ensure_ascii=True,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
return sha256(payload.encode("utf-8")).hexdigest()
|
||||
+4
@@ -82,6 +82,9 @@ from yuqei_contract_api.persistence.files import (
|
||||
FilePreview,
|
||||
)
|
||||
from yuqei_contract_api.persistence.governance import not_deleted
|
||||
from yuqei_contract_api.persistence.event_guards import (
|
||||
install_privileged_identity_operation_event_guards,
|
||||
)
|
||||
from yuqei_contract_api.persistence.models import DEFAULT_TENANT_ID, Base, ContractFileModel, ContractModel
|
||||
from yuqei_contract_api.persistence.review_results import StructuredReviewCitationPage, StructuredReviewRiskPage
|
||||
from yuqei_contract_api.persistence.review_tasks import ReviewSourceDocument, ReviewTask, ReviewTaskRequest
|
||||
@@ -247,6 +250,7 @@ class SqlAlchemyContractRepository(ContractRepository):
|
||||
)
|
||||
if auto_create:
|
||||
Base.metadata.create_all(self.engine)
|
||||
install_privileged_identity_operation_event_guards(self.engine)
|
||||
if auto_create or seed_demo_data:
|
||||
self.identity.seed_defaults()
|
||||
if seed_demo_data:
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
from yuqei_contract_api.config import get_settings
|
||||
|
||||
|
||||
SERVICE_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_privileged_identity_operation_migration_upgrade_and_downgrade(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
database_path = tmp_path / "privileged-identity-migration.sqlite3"
|
||||
database_url = f"sqlite+pysqlite:///{database_path.as_posix()}"
|
||||
monkeypatch.setenv("CONTRACT_API_DATABASE_URL", database_url)
|
||||
get_settings.cache_clear()
|
||||
config = Config(str(SERVICE_ROOT / "alembic.ini"))
|
||||
|
||||
try:
|
||||
command.upgrade(config, "20260802_0036")
|
||||
engine = create_engine(database_url)
|
||||
inspector = inspect(engine)
|
||||
assert "privileged_identity_operations" not in inspector.get_table_names()
|
||||
|
||||
command.upgrade(config, "head")
|
||||
inspector = inspect(engine)
|
||||
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()
|
||||
operation_columns = {
|
||||
column["name"]
|
||||
for column in inspector.get_columns("privileged_identity_operations")
|
||||
}
|
||||
assert {
|
||||
"operation_id",
|
||||
"operation_type",
|
||||
"status",
|
||||
"request_payload_hash",
|
||||
"requested_by_account_id",
|
||||
"preview_hash",
|
||||
"expected_revisions",
|
||||
"expires_at",
|
||||
} <= operation_columns
|
||||
approval_unique_constraints = {
|
||||
item["name"]
|
||||
for item in inspector.get_unique_constraints(
|
||||
"privileged_identity_operation_approvals"
|
||||
)
|
||||
}
|
||||
assert "ux_privileged_identity_approvals_operation_account" in approval_unique_constraints
|
||||
event_columns = {
|
||||
column["name"]
|
||||
for column in inspector.get_columns("privileged_identity_operation_events")
|
||||
}
|
||||
assert event_columns == {
|
||||
"event_id",
|
||||
"operation_id",
|
||||
"sequence_no",
|
||||
"event_type",
|
||||
"status_before",
|
||||
"status_after",
|
||||
"trace_id",
|
||||
"actor_account_id",
|
||||
"actor_tenant_id",
|
||||
"actor_user_id",
|
||||
"snapshot_before",
|
||||
"snapshot_after",
|
||||
"snapshot_before_hash",
|
||||
"snapshot_after_hash",
|
||||
"created_at",
|
||||
}
|
||||
event_unique_constraints = {
|
||||
item["name"]
|
||||
for item in inspector.get_unique_constraints(
|
||||
"privileged_identity_operation_events"
|
||||
)
|
||||
}
|
||||
assert (
|
||||
"ux_privileged_identity_operation_events_operation_sequence"
|
||||
in event_unique_constraints
|
||||
)
|
||||
event_indexes = {
|
||||
item["name"]
|
||||
for item in inspector.get_indexes("privileged_identity_operation_events")
|
||||
}
|
||||
assert {
|
||||
"ix_privileged_identity_operation_events_operation_created",
|
||||
"ix_privileged_identity_operation_events_type_created",
|
||||
"ix_privileged_identity_operation_events_trace",
|
||||
} <= event_indexes
|
||||
with engine.connect() as connection:
|
||||
event_triggers = {
|
||||
row[0]
|
||||
for row in connection.exec_driver_sql(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'trigger'"
|
||||
)
|
||||
}
|
||||
assert {
|
||||
"trg_privileged_identity_operation_events_no_update",
|
||||
"trg_privileged_identity_operation_events_no_delete",
|
||||
} <= event_triggers
|
||||
event_foreign_keys = inspector.get_foreign_keys(
|
||||
"privileged_identity_operation_events"
|
||||
)
|
||||
operation_foreign_key = next(
|
||||
item
|
||||
for item in event_foreign_keys
|
||||
if item["referred_table"] == "privileged_identity_operations"
|
||||
)
|
||||
assert operation_foreign_key.get("options", {}).get("ondelete") in {None, "NO ACTION"}
|
||||
|
||||
command.downgrade(config, "20260802_0036")
|
||||
downgraded_tables = inspect(engine).get_table_names()
|
||||
assert "privileged_identity_operations" not in downgraded_tables
|
||||
assert "privileged_identity_operation_approvals" not in downgraded_tables
|
||||
assert "privileged_identity_operation_events" not in downgraded_tables
|
||||
|
||||
command.upgrade(config, "head")
|
||||
reupgraded_tables = inspect(engine).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
|
||||
engine.dispose()
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
+724
@@ -0,0 +1,724 @@
|
||||
import json
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import DBAPIError
|
||||
|
||||
from yuqei_contract_api.anti_abuse import contract_endpoint_risk_policies, match_policy
|
||||
from yuqei_contract_api.config import ContractApiSettings
|
||||
from yuqei_contract_api.main import create_app
|
||||
from yuqei_contract_api.persistence.models import (
|
||||
GlobalAccountModel,
|
||||
PrivilegedIdentityOperationApprovalModel,
|
||||
PrivilegedIdentityOperationEventModel,
|
||||
PrivilegedIdentityOperationModel,
|
||||
TenantUserModel,
|
||||
WorkspaceMembershipModel,
|
||||
)
|
||||
from yuqei_contract_api.privileged_identity import (
|
||||
PrivilegedIdentityOperationError,
|
||||
privileged_payload_hash,
|
||||
)
|
||||
|
||||
|
||||
AUTH_VALUE = "x" * 48
|
||||
|
||||
|
||||
def make_settings(tmp_path: Path) -> ContractApiSettings:
|
||||
return ContractApiSettings(
|
||||
environment="staging",
|
||||
database_url=f"sqlite+pysqlite:///{tmp_path / 'privileged-identity.db'}",
|
||||
database_auto_create=True,
|
||||
seed_demo_data=False,
|
||||
file_storage_dir=str(tmp_path / "files"),
|
||||
web_proxy_shared_secret=AUTH_VALUE,
|
||||
require_web_proxy_auth=True,
|
||||
)
|
||||
|
||||
|
||||
def identity_headers(user_id: str = "user-current-admin") -> dict[str, str]:
|
||||
return {
|
||||
"X-Contract-Proxy-Key": AUTH_VALUE,
|
||||
"X-Tenant-Id": "default",
|
||||
"X-User-Id": user_id,
|
||||
}
|
||||
|
||||
|
||||
def add_identity(
|
||||
repository,
|
||||
*,
|
||||
account_id: str,
|
||||
user_id: str,
|
||||
email: str,
|
||||
platform_admin: bool,
|
||||
) -> None:
|
||||
now = datetime.now(UTC)
|
||||
with repository._session_factory.begin() as session:
|
||||
session.add(
|
||||
GlobalAccountModel(
|
||||
id=account_id,
|
||||
email=email,
|
||||
display_name=user_id,
|
||||
status="active",
|
||||
email_verified_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
TenantUserModel(
|
||||
id=user_id,
|
||||
tenant_id="default",
|
||||
email=email,
|
||||
display_name=user_id,
|
||||
role="admin" if platform_admin else "business",
|
||||
status="active",
|
||||
title=None,
|
||||
department=None,
|
||||
phone=None,
|
||||
platform_permissions=["platform_admin"] if platform_admin else [],
|
||||
settings={},
|
||||
invited_at=now,
|
||||
last_login_at=None,
|
||||
created_by="privileged-operation-test",
|
||||
updated_by="privileged-operation-test",
|
||||
deleted_at=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
WorkspaceMembershipModel(
|
||||
id=f"membership-{user_id}",
|
||||
account_id=account_id,
|
||||
tenant_id="default",
|
||||
tenant_user_id=user_id,
|
||||
role="admin" if platform_admin else "business",
|
||||
status="active",
|
||||
is_owner=False,
|
||||
joined_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def grant_preview(client: TestClient, *, user_id: str = "user-governed-target") -> dict:
|
||||
response = client.post(
|
||||
"/api/v1/identity/privileged-operations/preview",
|
||||
headers=identity_headers(),
|
||||
json={
|
||||
"operation_type": "platform_admin_grant",
|
||||
"target_refs": {
|
||||
"target_tenant_id": "default",
|
||||
"target_user_id": user_id,
|
||||
},
|
||||
"evidence_refs": [],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def submit_from_preview(
|
||||
client: TestClient,
|
||||
preview: dict,
|
||||
*,
|
||||
operation_id: str,
|
||||
reason: str = "Approved identity governance test request",
|
||||
expected_status: int = 201,
|
||||
trace_id: str | None = None,
|
||||
) -> dict:
|
||||
headers = identity_headers()
|
||||
if trace_id is not None:
|
||||
headers["X-Trace-Id"] = trace_id
|
||||
response = client.post(
|
||||
"/api/v1/identity/privileged-operations",
|
||||
headers=headers,
|
||||
json={
|
||||
"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": reason,
|
||||
"ticket_reference": "SUP-2026-0802-001",
|
||||
},
|
||||
)
|
||||
assert response.status_code == expected_status, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def test_preview_is_platform_only_read_only_and_minimally_disclosed(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-governed-target",
|
||||
user_id="user-governed-target",
|
||||
email="governed-target@example.com",
|
||||
platform_admin=False,
|
||||
)
|
||||
|
||||
before = next(
|
||||
item
|
||||
for item in repository.list_users(tenant_id="default").items
|
||||
if item.id == "user-governed-target"
|
||||
)
|
||||
forbidden = client.post(
|
||||
"/api/v1/identity/privileged-operations/preview",
|
||||
headers=identity_headers("user-governed-target"),
|
||||
json={
|
||||
"operation_type": "platform_admin_grant",
|
||||
"target_refs": {"target_tenant_id": "default", "target_user_id": "user-governed-target"},
|
||||
},
|
||||
)
|
||||
preview = grant_preview(client)
|
||||
after = next(
|
||||
item
|
||||
for item in repository.list_users(tenant_id="default").items
|
||||
if item.id == "user-governed-target"
|
||||
)
|
||||
events_forbidden = client.get(
|
||||
"/api/v1/identity/privileged-operations/operation-not-visible/events",
|
||||
headers=identity_headers("user-governed-target"),
|
||||
)
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert forbidden.json()["detail"]["code"] == "platform_admin_required"
|
||||
assert events_forbidden.status_code == 403
|
||||
assert events_forbidden.json()["detail"]["code"] == "platform_admin_required"
|
||||
assert preview["operation_type"] == "platform_admin_grant"
|
||||
assert preview["impact_summary"]["platform_permissions_changed"] == 1
|
||||
assert preview["blocking_issues"] == []
|
||||
assert preview["expected_revisions"]
|
||||
assert before == after
|
||||
serialized = json.dumps(preview)
|
||||
for forbidden_key in ("password_hash", "token", "session", "email"):
|
||||
assert forbidden_key not in serialized
|
||||
|
||||
|
||||
def test_preview_returns_stable_400_404_and_422_errors(tmp_path: Path) -> None:
|
||||
client = TestClient(create_app(make_settings(tmp_path)))
|
||||
invalid_json_value = client.post(
|
||||
"/api/v1/identity/privileged-operations/preview",
|
||||
headers=identity_headers(),
|
||||
json={
|
||||
"operation_type": "platform_admin_grant",
|
||||
"target_refs": {"target_tenant_id": "default", "target_user_id": 1.5},
|
||||
},
|
||||
)
|
||||
missing = client.post(
|
||||
"/api/v1/identity/privileged-operations/preview",
|
||||
headers=identity_headers(),
|
||||
json={
|
||||
"operation_type": "platform_admin_grant",
|
||||
"target_refs": {"target_tenant_id": "default", "target_user_id": "missing-user"},
|
||||
},
|
||||
)
|
||||
same_account = client.post(
|
||||
"/api/v1/identity/privileged-operations/preview",
|
||||
headers=identity_headers(),
|
||||
json={
|
||||
"operation_type": "account_merge",
|
||||
"target_refs": {
|
||||
"source_account_id": "same-account",
|
||||
"target_account_id": "same-account",
|
||||
},
|
||||
},
|
||||
)
|
||||
unexpected_sensitive_ref = client.post(
|
||||
"/api/v1/identity/privileged-operations/preview",
|
||||
headers=identity_headers(),
|
||||
json={
|
||||
"operation_type": "platform_admin_grant",
|
||||
"target_refs": {
|
||||
"target_tenant_id": "default",
|
||||
"target_user_id": "missing-user",
|
||||
"password_hash": "must-not-be-stored",
|
||||
},
|
||||
},
|
||||
)
|
||||
missing_events = client.get(
|
||||
"/api/v1/identity/privileged-operations/operation-missing/events",
|
||||
headers=identity_headers(),
|
||||
)
|
||||
|
||||
assert invalid_json_value.status_code == 400
|
||||
assert invalid_json_value.json()["detail"]["code"] == "invalid_target_refs"
|
||||
assert missing.status_code == 404
|
||||
assert missing.json()["detail"]["code"] == "identity_target_not_found"
|
||||
assert same_account.status_code == 422
|
||||
assert same_account.json()["detail"]["code"] == "same_account"
|
||||
assert unexpected_sensitive_ref.status_code == 400
|
||||
assert unexpected_sensitive_ref.json()["detail"]["code"] == "unexpected_target_reference"
|
||||
assert "must-not-be-stored" not in unexpected_sensitive_ref.text
|
||||
assert missing_events.status_code == 404
|
||||
assert missing_events.json()["detail"]["code"] == "operation_not_found"
|
||||
|
||||
|
||||
def test_list_and_detail_map_governance_repository_errors(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
app = create_app(make_settings(tmp_path))
|
||||
client = TestClient(app)
|
||||
privileged_repository = app.state.contract_repository.identity.privileged_operations
|
||||
|
||||
def fail_with_expiration_conflict(*_args, **_kwargs):
|
||||
raise PrivilegedIdentityOperationError(
|
||||
status_code=409,
|
||||
code="expiration_conflict",
|
||||
message="The expired privileged operation could not be materialized.",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
privileged_repository,
|
||||
"list_operations",
|
||||
fail_with_expiration_conflict,
|
||||
)
|
||||
list_response = client.get(
|
||||
"/api/v1/identity/privileged-operations",
|
||||
headers=identity_headers(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
privileged_repository,
|
||||
"get_operation",
|
||||
fail_with_expiration_conflict,
|
||||
)
|
||||
detail_response = client.get(
|
||||
"/api/v1/identity/privileged-operations/operation-expiration-conflict",
|
||||
headers=identity_headers(),
|
||||
)
|
||||
|
||||
assert list_response.status_code == 409
|
||||
assert list_response.json()["detail"]["code"] == "expiration_conflict"
|
||||
assert detail_response.status_code == 409
|
||||
assert detail_response.json()["detail"]["code"] == "expiration_conflict"
|
||||
|
||||
|
||||
def test_submission_is_persistent_idempotent_and_rejects_stale_preview(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-governed-target",
|
||||
user_id="user-governed-target",
|
||||
email="governed-target@example.com",
|
||||
platform_admin=False,
|
||||
)
|
||||
preview = grant_preview(client)
|
||||
first = submit_from_preview(
|
||||
client,
|
||||
preview,
|
||||
operation_id="operation-idempotent-001",
|
||||
trace_id="trace-privileged-submit-001",
|
||||
)
|
||||
second = submit_from_preview(
|
||||
client,
|
||||
preview,
|
||||
operation_id="operation-idempotent-001",
|
||||
expected_status=200,
|
||||
trace_id="trace-privileged-submit-replay",
|
||||
)
|
||||
conflict = client.post(
|
||||
"/api/v1/identity/privileged-operations",
|
||||
headers=identity_headers(),
|
||||
json={
|
||||
"operation_id": "operation-idempotent-001",
|
||||
"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": "A materially different identity governance request",
|
||||
"ticket_reference": "SUP-2026-0802-001",
|
||||
},
|
||||
)
|
||||
detail = client.get(
|
||||
"/api/v1/identity/privileged-operations/operation-idempotent-001",
|
||||
headers=identity_headers(),
|
||||
)
|
||||
page = client.get(
|
||||
"/api/v1/identity/privileged-operations?status=pending_approval",
|
||||
headers=identity_headers(),
|
||||
)
|
||||
events = client.get(
|
||||
"/api/v1/identity/privileged-operations/operation-idempotent-001/events",
|
||||
headers=identity_headers(),
|
||||
)
|
||||
|
||||
assert first == second
|
||||
assert first["status"] == "pending_approval"
|
||||
assert first["approval_count"] == 0
|
||||
assert conflict.status_code == 409
|
||||
assert conflict.json()["detail"]["code"] == "idempotency_conflict"
|
||||
assert detail.status_code == 200
|
||||
assert page.status_code == 200
|
||||
assert page.json()["total"] == 1
|
||||
assert events.status_code == 200
|
||||
assert events.json()["total"] == 1
|
||||
submitted_event = events.json()["items"][0]
|
||||
assert submitted_event["sequence_no"] == 1
|
||||
assert submitted_event["event_type"] == "operation_submitted"
|
||||
assert submitted_event["status_before"] is None
|
||||
assert submitted_event["status_after"] == "pending_approval"
|
||||
assert submitted_event["trace_id"] == "trace-privileged-submit-001"
|
||||
assert submitted_event["snapshot_before"] == {"exists": False}
|
||||
assert submitted_event["snapshot_before_hash"] == privileged_payload_hash(
|
||||
submitted_event["snapshot_before"]
|
||||
)
|
||||
assert submitted_event["snapshot_after_hash"] == privileged_payload_hash(
|
||||
submitted_event["snapshot_after"]
|
||||
)
|
||||
serialized_snapshots = json.dumps(
|
||||
[submitted_event["snapshot_before"], submitted_event["snapshot_after"]]
|
||||
).lower()
|
||||
assert "user-governed-target" not in serialized_snapshots
|
||||
for forbidden_term in ("email", "name", "token", "credential", "secret", "password"):
|
||||
assert forbidden_term not in serialized_snapshots
|
||||
with repository._session_factory() as session:
|
||||
assert session.scalar(select(PrivilegedIdentityOperationModel).where(
|
||||
PrivilegedIdentityOperationModel.operation_id == "operation-idempotent-001"
|
||||
)) is not None
|
||||
target = session.get(TenantUserModel, "user-governed-target")
|
||||
assert target is not None
|
||||
assert target.platform_permissions == []
|
||||
stored_events = session.execute(
|
||||
select(PrivilegedIdentityOperationEventModel).where(
|
||||
PrivilegedIdentityOperationEventModel.operation_id == "operation-idempotent-001"
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(stored_events) == 1
|
||||
|
||||
with pytest.raises(DBAPIError):
|
||||
with repository._session_factory.begin() as session:
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE privileged_identity_operation_events "
|
||||
"SET trace_id = 'tampered' WHERE event_id = :event_id"
|
||||
),
|
||||
{"event_id": submitted_event["event_id"]},
|
||||
)
|
||||
with pytest.raises(DBAPIError):
|
||||
with repository._session_factory.begin() as session:
|
||||
session.execute(
|
||||
text(
|
||||
"DELETE FROM privileged_identity_operation_events "
|
||||
"WHERE event_id = :event_id"
|
||||
),
|
||||
{"event_id": submitted_event["event_id"]},
|
||||
)
|
||||
|
||||
stale_preview = grant_preview(client)
|
||||
with repository._session_factory.begin() as session:
|
||||
target = session.get(TenantUserModel, "user-governed-target")
|
||||
assert target is not None
|
||||
target.updated_at = datetime.now(UTC) + timedelta(seconds=1)
|
||||
stale = client.post(
|
||||
"/api/v1/identity/privileged-operations",
|
||||
headers=identity_headers(),
|
||||
json={
|
||||
"operation_id": "operation-stale-001",
|
||||
"operation_type": stale_preview["operation_type"],
|
||||
"target_refs": stale_preview["target_refs"],
|
||||
"evidence_refs": stale_preview["evidence_refs"],
|
||||
"preview_hash": stale_preview["preview_hash"],
|
||||
"preview_expires_at": stale_preview["expires_at"],
|
||||
"reason": "Stale preview must be rejected by revision matching",
|
||||
"ticket_reference": "SUP-2026-0802-002",
|
||||
},
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["detail"]["code"] == "stale_preview"
|
||||
|
||||
|
||||
def test_two_distinct_current_admins_are_required_and_requester_cannot_approve(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-governed-target",
|
||||
user_id="user-governed-target",
|
||||
email="governed-target@example.com",
|
||||
platform_admin=False,
|
||||
)
|
||||
add_identity(
|
||||
repository,
|
||||
account_id="account-approver-one",
|
||||
user_id="user-approver-one",
|
||||
email="approver-one@example.com",
|
||||
platform_admin=True,
|
||||
)
|
||||
add_identity(
|
||||
repository,
|
||||
account_id="account-approver-two",
|
||||
user_id="user-approver-two",
|
||||
email="approver-two@example.com",
|
||||
platform_admin=True,
|
||||
)
|
||||
preview = grant_preview(client)
|
||||
operation = submit_from_preview(client, preview, operation_id="operation-two-person-001")
|
||||
decision = {
|
||||
"decision": "approve",
|
||||
"comment": "Reviewed target and ticket evidence",
|
||||
"preview_hash": operation["preview_hash"],
|
||||
}
|
||||
|
||||
self_approval = client.post(
|
||||
"/api/v1/identity/privileged-operations/operation-two-person-001/approvals",
|
||||
headers=identity_headers(),
|
||||
json=decision,
|
||||
)
|
||||
first = client.post(
|
||||
"/api/v1/identity/privileged-operations/operation-two-person-001/approvals",
|
||||
headers={**identity_headers("user-approver-one"), "X-Trace-Id": "trace-approval-one-001"},
|
||||
json=decision,
|
||||
)
|
||||
duplicate = client.post(
|
||||
"/api/v1/identity/privileged-operations/operation-two-person-001/approvals",
|
||||
headers=identity_headers("user-approver-one"),
|
||||
json=decision,
|
||||
)
|
||||
second = client.post(
|
||||
"/api/v1/identity/privileged-operations/operation-two-person-001/approvals",
|
||||
headers={**identity_headers("user-approver-two"), "X-Trace-Id": "trace-approval-two-001"},
|
||||
json=decision,
|
||||
)
|
||||
events = client.get(
|
||||
"/api/v1/identity/privileged-operations/operation-two-person-001/events",
|
||||
headers=identity_headers(),
|
||||
)
|
||||
|
||||
assert self_approval.status_code == 403
|
||||
assert self_approval.json()["detail"]["code"] == "requester_cannot_approve"
|
||||
assert first.status_code == 200
|
||||
assert first.json()["status"] == "pending_approval"
|
||||
assert first.json()["approval_count"] == 1
|
||||
assert duplicate.status_code == 200
|
||||
assert duplicate.json()["approval_count"] == 1
|
||||
assert second.status_code == 200
|
||||
assert second.json()["status"] == "approved"
|
||||
assert second.json()["approval_count"] == 2
|
||||
assert events.status_code == 200
|
||||
assert events.json()["total"] == 3
|
||||
assert [item["sequence_no"] for item in events.json()["items"]] == [1, 2, 3]
|
||||
assert [item["event_type"] for item in events.json()["items"]] == [
|
||||
"operation_submitted",
|
||||
"approval_recorded",
|
||||
"approval_recorded",
|
||||
]
|
||||
assert events.json()["items"][1]["status_before"] == "pending_approval"
|
||||
assert events.json()["items"][1]["status_after"] == "pending_approval"
|
||||
assert events.json()["items"][2]["status_after"] == "approved"
|
||||
assert events.json()["items"][1]["trace_id"] == "trace-approval-one-001"
|
||||
assert events.json()["items"][2]["trace_id"] == "trace-approval-two-001"
|
||||
with repository._session_factory() as session:
|
||||
target = session.get(TenantUserModel, "user-governed-target")
|
||||
assert target is not None
|
||||
assert target.platform_permissions == []
|
||||
|
||||
|
||||
def test_same_approver_concurrent_decisions_are_idempotent(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-governed-target",
|
||||
user_id="user-governed-target",
|
||||
email="governed-target@example.com",
|
||||
platform_admin=False,
|
||||
)
|
||||
add_identity(
|
||||
repository,
|
||||
account_id="account-approver-one",
|
||||
user_id="user-approver-one",
|
||||
email="approver-one@example.com",
|
||||
platform_admin=True,
|
||||
)
|
||||
preview = grant_preview(client)
|
||||
operation = submit_from_preview(client, preview, operation_id="operation-concurrent-001")
|
||||
decision = {
|
||||
"decision": "approve",
|
||||
"comment": "Concurrent replay of the same approval",
|
||||
"preview_hash": operation["preview_hash"],
|
||||
}
|
||||
barrier = Barrier(2)
|
||||
|
||||
def approve() -> tuple[int, dict]:
|
||||
barrier.wait(timeout=5)
|
||||
with TestClient(app) as concurrent_client:
|
||||
response = concurrent_client.post(
|
||||
"/api/v1/identity/privileged-operations/operation-concurrent-001/approvals",
|
||||
headers=identity_headers("user-approver-one"),
|
||||
json=decision,
|
||||
)
|
||||
return response.status_code, response.json()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(lambda _index: approve(), range(2)))
|
||||
|
||||
assert [status for status, _payload in results] == [200, 200]
|
||||
assert all(payload["approval_count"] == 1 for _status, payload in results)
|
||||
assert all(len(payload["approvals"]) == 1 for _status, payload in results)
|
||||
with repository._session_factory() as session:
|
||||
approvals = session.execute(
|
||||
select(PrivilegedIdentityOperationApprovalModel).where(
|
||||
PrivilegedIdentityOperationApprovalModel.operation_id == "operation-concurrent-001"
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(approvals) == 1
|
||||
events = session.execute(
|
||||
select(PrivilegedIdentityOperationEventModel).where(
|
||||
PrivilegedIdentityOperationEventModel.operation_id == "operation-concurrent-001"
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(events) == 2
|
||||
assert sorted(event.sequence_no for event in events) == [1, 2]
|
||||
|
||||
|
||||
def test_rejection_is_terminal_and_expired_operation_returns_410(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-governed-target",
|
||||
user_id="user-governed-target",
|
||||
email="governed-target@example.com",
|
||||
platform_admin=False,
|
||||
)
|
||||
add_identity(
|
||||
repository,
|
||||
account_id="account-approver-one",
|
||||
user_id="user-approver-one",
|
||||
email="approver-one@example.com",
|
||||
platform_admin=True,
|
||||
)
|
||||
add_identity(
|
||||
repository,
|
||||
account_id="account-approver-two",
|
||||
user_id="user-approver-two",
|
||||
email="approver-two@example.com",
|
||||
platform_admin=True,
|
||||
)
|
||||
preview = grant_preview(client)
|
||||
rejected = submit_from_preview(client, preview, operation_id="operation-rejected-001")
|
||||
rejection = client.post(
|
||||
"/api/v1/identity/privileged-operations/operation-rejected-001/approvals",
|
||||
headers={**identity_headers("user-approver-one"), "X-Trace-Id": "trace-rejection-001"},
|
||||
json={
|
||||
"decision": "reject",
|
||||
"comment": "The ticket evidence is insufficient",
|
||||
"preview_hash": rejected["preview_hash"],
|
||||
},
|
||||
)
|
||||
after_rejection = client.post(
|
||||
"/api/v1/identity/privileged-operations/operation-rejected-001/approvals",
|
||||
headers=identity_headers("user-approver-two"),
|
||||
json={
|
||||
"decision": "approve",
|
||||
"comment": "Attempt after terminal rejection",
|
||||
"preview_hash": rejected["preview_hash"],
|
||||
},
|
||||
)
|
||||
assert rejection.status_code == 200
|
||||
assert rejection.json()["status"] == "rejected"
|
||||
assert after_rejection.status_code == 409
|
||||
assert after_rejection.json()["detail"]["code"] == "invalid_operation_state"
|
||||
rejected_events = client.get(
|
||||
"/api/v1/identity/privileged-operations/operation-rejected-001/events",
|
||||
headers=identity_headers(),
|
||||
)
|
||||
assert rejected_events.status_code == 200
|
||||
assert rejected_events.json()["total"] == 2
|
||||
assert rejected_events.json()["items"][1]["event_type"] == "operation_rejected"
|
||||
assert rejected_events.json()["items"][1]["status_after"] == "rejected"
|
||||
assert rejected_events.json()["items"][1]["trace_id"] == "trace-rejection-001"
|
||||
|
||||
expiring = submit_from_preview(client, preview, operation_id="operation-expired-001")
|
||||
with repository._session_factory.begin() as session:
|
||||
model = session.get(PrivilegedIdentityOperationModel, "operation-expired-001")
|
||||
assert model is not None
|
||||
model.expires_at = datetime.now(UTC) - timedelta(seconds=1)
|
||||
expired = client.post(
|
||||
"/api/v1/identity/privileged-operations/operation-expired-001/approvals",
|
||||
headers={**identity_headers("user-approver-one"), "X-Trace-Id": "trace-expiration-001"},
|
||||
json={
|
||||
"decision": "approve",
|
||||
"comment": "Attempt after operation expiry",
|
||||
"preview_hash": expiring["preview_hash"],
|
||||
},
|
||||
)
|
||||
assert expired.status_code == 410
|
||||
assert expired.json()["detail"]["code"] == "operation_expired"
|
||||
detail = client.get(
|
||||
"/api/v1/identity/privileged-operations/operation-expired-001",
|
||||
headers=identity_headers(),
|
||||
)
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["status"] == "expired"
|
||||
expired_events = client.get(
|
||||
"/api/v1/identity/privileged-operations/operation-expired-001/events",
|
||||
headers=identity_headers(),
|
||||
)
|
||||
assert expired_events.status_code == 200
|
||||
assert expired_events.json()["total"] == 2
|
||||
assert expired_events.json()["items"][1]["event_type"] == "operation_expired"
|
||||
assert expired_events.json()["items"][1]["status_before"] == "pending_approval"
|
||||
assert expired_events.json()["items"][1]["status_after"] == "expired"
|
||||
assert expired_events.json()["items"][1]["trace_id"] == "trace-expiration-001"
|
||||
with repository._session_factory() as session:
|
||||
stored = session.get(PrivilegedIdentityOperationModel, "operation-expired-001")
|
||||
assert stored is not None
|
||||
assert stored.status == "expired"
|
||||
stored_events = session.execute(
|
||||
select(PrivilegedIdentityOperationEventModel).where(
|
||||
PrivilegedIdentityOperationEventModel.operation_id == "operation-expired-001"
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(stored_events) == 2
|
||||
|
||||
|
||||
def test_privileged_operation_routes_are_s3_sensitive() -> None:
|
||||
policies = contract_endpoint_risk_policies("/api/v1")
|
||||
for method, path in (
|
||||
("POST", "/api/v1/identity/privileged-operations/preview"),
|
||||
("POST", "/api/v1/identity/privileged-operations"),
|
||||
("POST", "/api/v1/identity/privileged-operations/op-001/approvals"),
|
||||
("GET", "/api/v1/identity/privileged-operations"),
|
||||
("GET", "/api/v1/identity/privileged-operations/op-001"),
|
||||
("GET", "/api/v1/identity/privileged-operations/op-001/events"),
|
||||
):
|
||||
policy = match_policy(method, path, policies)
|
||||
assert policy is not None
|
||||
assert policy.data_class == "S3"
|
||||
assert policy.sensitive is True
|
||||
|
||||
|
||||
def test_no_execution_or_destructive_identity_route_is_registered(tmp_path: Path) -> None:
|
||||
paths = {
|
||||
path
|
||||
for path in create_app(make_settings(tmp_path)).openapi()["paths"]
|
||||
if "/identity/" in path
|
||||
}
|
||||
forbidden_suffixes = (
|
||||
"/execute",
|
||||
"/rollback",
|
||||
"/merge",
|
||||
"/split",
|
||||
"/recover",
|
||||
"/grant",
|
||||
"/revoke",
|
||||
)
|
||||
assert not any(path.endswith(forbidden_suffixes) for path in paths)
|
||||
+554
@@ -0,0 +1,554 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Barrier
|
||||
from typing import Callable, Iterator, TypeVar
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, func, select, text
|
||||
from sqlalchemy.engine import URL, make_url
|
||||
from sqlalchemy.exc import DBAPIError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from yuqei_contract_api.logging import set_trace_id
|
||||
from yuqei_contract_api.persistence.models import (
|
||||
GlobalAccountModel,
|
||||
PrivilegedIdentityOperationApprovalModel,
|
||||
PrivilegedIdentityOperationEventModel,
|
||||
PrivilegedIdentityOperationModel,
|
||||
TenantModel,
|
||||
TenantUserModel,
|
||||
WorkspaceMembershipModel,
|
||||
)
|
||||
from yuqei_contract_api.persistence.privileged_identity import PrivilegedIdentityOperationRepository
|
||||
from yuqei_contract_api.privileged_identity import (
|
||||
PrivilegedIdentityOperationApprovalRequest,
|
||||
PrivilegedIdentityOperationError,
|
||||
PrivilegedIdentityOperationPreviewRequest,
|
||||
PrivilegedIdentityOperationSubmitRequest,
|
||||
privileged_payload_hash,
|
||||
)
|
||||
|
||||
|
||||
_DATABASE_ENV = "CONTRACT_API_POSTGRES_CONCURRENCY_URL"
|
||||
_SERVICE_ROOT = Path(__file__).resolve().parents[1]
|
||||
_TENANT_ID = "pg-governance-tenant"
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PostgresGovernanceHarness:
|
||||
schema_name: str
|
||||
session_factory: sessionmaker[Session]
|
||||
repository: PrivilegedIdentityOperationRepository
|
||||
|
||||
|
||||
def _isolated_url(base_url: str, schema_name: str, *, include_public: bool = False) -> URL:
|
||||
url = make_url(base_url)
|
||||
query = dict(url.query)
|
||||
search_path = f"{schema_name},public" if include_public else schema_name
|
||||
query["options"] = f"-csearch_path={search_path}"
|
||||
return url.set(query=query)
|
||||
|
||||
|
||||
def _run_migrations(base_url: str, schema_name: str) -> None:
|
||||
source_path = str(_SERVICE_ROOT / "src")
|
||||
migration_phases = (
|
||||
("20260624_0007", False),
|
||||
("20260624_0009", True),
|
||||
("head", False),
|
||||
)
|
||||
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."
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def postgres_governance() -> Iterator[PostgresGovernanceHarness]:
|
||||
base_url = os.environ.get(_DATABASE_ENV)
|
||||
if not base_url:
|
||||
pytest.skip(f"Set {_DATABASE_ENV} to run the isolated PostgreSQL concurrency test.")
|
||||
|
||||
schema_name = f"privileged_governance_{uuid4().hex[:16]}"
|
||||
control_engine = create_engine(make_url(base_url), pool_pre_ping=True)
|
||||
isolated_engine = None
|
||||
created = False
|
||||
try:
|
||||
with control_engine.begin() as connection:
|
||||
connection.exec_driver_sql(f'CREATE SCHEMA "{schema_name}"')
|
||||
created = True
|
||||
with control_engine.begin() as connection:
|
||||
connection.exec_driver_sql(
|
||||
f'CREATE TABLE "{schema_name}".contract_api_alembic_version '
|
||||
"(version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
|
||||
)
|
||||
database_url = _isolated_url(base_url, schema_name)
|
||||
_run_migrations(base_url, schema_name)
|
||||
isolated_engine = create_engine(database_url, pool_pre_ping=True)
|
||||
session_factory = sessionmaker(
|
||||
bind=isolated_engine,
|
||||
expire_on_commit=False,
|
||||
future=True,
|
||||
)
|
||||
with isolated_engine.connect() as connection:
|
||||
assert connection.scalar(
|
||||
text("SELECT version_num FROM contract_api_alembic_version")
|
||||
) == "20260802_0039"
|
||||
migrated_tables = set(
|
||||
connection.execute(
|
||||
text("SELECT tablename FROM pg_tables WHERE schemaname = :schema_name"),
|
||||
{"schema_name": schema_name},
|
||||
).scalars()
|
||||
)
|
||||
assert {
|
||||
"contract_api_alembic_version",
|
||||
"identity_accounts",
|
||||
"privileged_identity_operations",
|
||||
"privileged_identity_operation_approvals",
|
||||
"privileged_identity_operation_events",
|
||||
} <= migrated_tables
|
||||
yield PostgresGovernanceHarness(
|
||||
schema_name=schema_name,
|
||||
session_factory=session_factory,
|
||||
repository=PrivilegedIdentityOperationRepository(session_factory),
|
||||
)
|
||||
finally:
|
||||
if isolated_engine is not None:
|
||||
isolated_engine.dispose()
|
||||
if created:
|
||||
with control_engine.begin() as connection:
|
||||
connection.exec_driver_sql(f'DROP SCHEMA "{schema_name}" CASCADE')
|
||||
assert connection.scalar(
|
||||
text("SELECT count(*) FROM pg_namespace WHERE nspname = :schema_name"),
|
||||
{"schema_name": schema_name},
|
||||
) == 0
|
||||
control_engine.dispose()
|
||||
|
||||
|
||||
def _seed_identity(
|
||||
session: Session,
|
||||
*,
|
||||
account_id: str,
|
||||
user_id: str,
|
||||
platform_admin: bool,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
email = f"{user_id}@postgres-concurrency.invalid"
|
||||
session.add(
|
||||
GlobalAccountModel(
|
||||
id=account_id,
|
||||
email=email,
|
||||
display_name=user_id,
|
||||
status="active",
|
||||
email_verified_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
TenantUserModel(
|
||||
id=user_id,
|
||||
tenant_id=_TENANT_ID,
|
||||
email=email,
|
||||
display_name=user_id,
|
||||
role="admin" if platform_admin else "business",
|
||||
status="active",
|
||||
title=None,
|
||||
department=None,
|
||||
phone=None,
|
||||
platform_permissions=["platform_admin"] if platform_admin else [],
|
||||
settings={},
|
||||
invited_at=now,
|
||||
last_login_at=None,
|
||||
created_by="postgres-concurrency-test",
|
||||
updated_by="postgres-concurrency-test",
|
||||
deleted_at=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
WorkspaceMembershipModel(
|
||||
id=f"membership-{user_id}",
|
||||
account_id=account_id,
|
||||
tenant_id=_TENANT_ID,
|
||||
tenant_user_id=user_id,
|
||||
role="admin" if platform_admin else "business",
|
||||
status="active",
|
||||
is_owner=False,
|
||||
joined_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _seed_governance_identities(session_factory: sessionmaker[Session]) -> None:
|
||||
now = datetime.now(UTC)
|
||||
with session_factory.begin() as session:
|
||||
session.add(
|
||||
TenantModel(
|
||||
id=_TENANT_ID,
|
||||
name="PostgreSQL governance test",
|
||||
workspace_type="system",
|
||||
owner_user_id="user-requester",
|
||||
legal_name=None,
|
||||
status="active",
|
||||
plan=None,
|
||||
region=None,
|
||||
settings={},
|
||||
created_by="postgres-concurrency-test",
|
||||
updated_by="postgres-concurrency-test",
|
||||
deleted_at=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
for account_id, user_id, is_admin in (
|
||||
("account-requester", "user-requester", True),
|
||||
("account-approver-one", "user-approver-one", True),
|
||||
("account-approver-two", "user-approver-two", True),
|
||||
("account-target", "user-target", False),
|
||||
):
|
||||
_seed_identity(
|
||||
session,
|
||||
account_id=account_id,
|
||||
user_id=user_id,
|
||||
platform_admin=is_admin,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def _submission_request(
|
||||
repository: PrivilegedIdentityOperationRepository,
|
||||
operation_id: str,
|
||||
) -> PrivilegedIdentityOperationSubmitRequest:
|
||||
preview = repository.preview(
|
||||
PrivilegedIdentityOperationPreviewRequest(
|
||||
operation_type="platform_admin_grant",
|
||||
target_refs={
|
||||
"target_tenant_id": _TENANT_ID,
|
||||
"target_user_id": "user-target",
|
||||
},
|
||||
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 concurrency governance verification",
|
||||
ticket_reference="PG-CONCURRENCY-20260802",
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _trace(trace_id: str) -> Iterator[None]:
|
||||
set_trace_id(trace_id)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
set_trace_id(None)
|
||||
|
||||
|
||||
def _concurrent(
|
||||
traces: list[str],
|
||||
action: Callable[[str], _T],
|
||||
) -> list[_T]:
|
||||
barrier = Barrier(len(traces))
|
||||
|
||||
def invoke(trace_id: str) -> _T:
|
||||
with _trace(trace_id):
|
||||
barrier.wait(timeout=10)
|
||||
return action(trace_id)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=len(traces)) as executor:
|
||||
futures = [executor.submit(invoke, trace_id) for trace_id in traces]
|
||||
return [future.result(timeout=30) for future in futures]
|
||||
|
||||
|
||||
def _submit(
|
||||
repository: PrivilegedIdentityOperationRepository,
|
||||
request: PrivilegedIdentityOperationSubmitRequest,
|
||||
):
|
||||
return repository.submit(
|
||||
request,
|
||||
actor_tenant_id=_TENANT_ID,
|
||||
actor_user_id="user-requester",
|
||||
)
|
||||
|
||||
|
||||
def _approve(
|
||||
repository: PrivilegedIdentityOperationRepository,
|
||||
operation_id: str,
|
||||
preview_hash: str,
|
||||
actor_user_id: str,
|
||||
):
|
||||
return repository.decide(
|
||||
operation_id,
|
||||
PrivilegedIdentityOperationApprovalRequest(
|
||||
decision="approve",
|
||||
comment="PostgreSQL concurrent approval verification",
|
||||
preview_hash=preview_hash,
|
||||
),
|
||||
actor_tenant_id=_TENANT_ID,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
|
||||
|
||||
def _events(
|
||||
session_factory: sessionmaker[Session],
|
||||
operation_id: str,
|
||||
) -> list[PrivilegedIdentityOperationEventModel]:
|
||||
with session_factory() as session:
|
||||
return list(
|
||||
session.execute(
|
||||
select(PrivilegedIdentityOperationEventModel)
|
||||
.where(PrivilegedIdentityOperationEventModel.operation_id == operation_id)
|
||||
.order_by(PrivilegedIdentityOperationEventModel.sequence_no)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def _assert_event_integrity(events: list[PrivilegedIdentityOperationEventModel]) -> 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)
|
||||
serialized = json.dumps(
|
||||
{"before": event.snapshot_before, "after": event.snapshot_after},
|
||||
sort_keys=True,
|
||||
)
|
||||
assert "user-target" not in serialized
|
||||
assert "postgres-concurrency.invalid" not in serialized
|
||||
|
||||
|
||||
def test_postgresql_privileged_governance_concurrency(
|
||||
postgres_governance: PostgresGovernanceHarness,
|
||||
) -> None:
|
||||
repository = postgres_governance.repository
|
||||
session_factory = postgres_governance.session_factory
|
||||
_seed_governance_identities(session_factory)
|
||||
|
||||
idempotent_request = _submission_request(repository, "pg-idempotent-submit")
|
||||
submit_traces = ["trace-submit-a", "trace-submit-b"]
|
||||
submit_results = _concurrent(
|
||||
submit_traces,
|
||||
lambda _trace_id: _submit(repository, idempotent_request),
|
||||
)
|
||||
assert sorted(result.created for result in submit_results) == [False, True]
|
||||
idempotent_events = _events(session_factory, idempotent_request.operation_id)
|
||||
assert [event.event_type for event in idempotent_events] == ["operation_submitted"]
|
||||
assert idempotent_events[0].trace_id in submit_traces
|
||||
_assert_event_integrity(idempotent_events)
|
||||
with pytest.raises(DBAPIError):
|
||||
with session_factory.begin() as session:
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE privileged_identity_operation_events "
|
||||
"SET trace_id = 'tampered' WHERE event_id = :event_id"
|
||||
),
|
||||
{"event_id": idempotent_events[0].event_id},
|
||||
)
|
||||
with pytest.raises(DBAPIError):
|
||||
with session_factory.begin() as session:
|
||||
session.execute(
|
||||
text(
|
||||
"DELETE FROM privileged_identity_operation_events "
|
||||
"WHERE event_id = :event_id"
|
||||
),
|
||||
{"event_id": idempotent_events[0].event_id},
|
||||
)
|
||||
|
||||
same_request = _submission_request(repository, "pg-same-approver")
|
||||
with _trace("trace-same-submit"):
|
||||
same_operation = _submit(repository, same_request).item
|
||||
same_traces = ["trace-same-a", "trace-same-b"]
|
||||
same_results = _concurrent(
|
||||
same_traces,
|
||||
lambda _trace_id: _approve(
|
||||
repository,
|
||||
same_request.operation_id,
|
||||
same_operation.preview_hash,
|
||||
"user-approver-one",
|
||||
),
|
||||
)
|
||||
assert all(result.status == "pending_approval" for result in same_results)
|
||||
assert all(result.approval_count == 1 for result in same_results)
|
||||
same_events = _events(session_factory, same_request.operation_id)
|
||||
assert [event.event_type for event in same_events] == [
|
||||
"operation_submitted",
|
||||
"approval_recorded",
|
||||
]
|
||||
assert same_events[1].trace_id in same_traces
|
||||
with session_factory() as session:
|
||||
same_approval_count = session.scalar(
|
||||
select(func.count(PrivilegedIdentityOperationApprovalModel.id)).where(
|
||||
PrivilegedIdentityOperationApprovalModel.operation_id == same_request.operation_id
|
||||
)
|
||||
)
|
||||
assert same_approval_count == 1
|
||||
_assert_event_integrity(same_events)
|
||||
|
||||
distinct_request = _submission_request(repository, "pg-distinct-approvers")
|
||||
with _trace("trace-distinct-submit"):
|
||||
distinct_operation = _submit(repository, distinct_request).item
|
||||
distinct_traces = ["trace-distinct-one", "trace-distinct-two"]
|
||||
approvers = {
|
||||
"trace-distinct-one": "user-approver-one",
|
||||
"trace-distinct-two": "user-approver-two",
|
||||
}
|
||||
distinct_results = _concurrent(
|
||||
distinct_traces,
|
||||
lambda trace_id: _approve(
|
||||
repository,
|
||||
distinct_request.operation_id,
|
||||
distinct_operation.preview_hash,
|
||||
approvers[trace_id],
|
||||
),
|
||||
)
|
||||
assert {result.status for result in distinct_results} == {"pending_approval", "approved"}
|
||||
final_distinct = repository.get_operation(
|
||||
distinct_request.operation_id,
|
||||
actor_tenant_id=_TENANT_ID,
|
||||
actor_user_id="user-requester",
|
||||
)
|
||||
assert final_distinct is not None
|
||||
assert final_distinct.status == "approved"
|
||||
assert final_distinct.approval_count == 2
|
||||
distinct_events = _events(session_factory, distinct_request.operation_id)
|
||||
assert [event.event_type for event in distinct_events] == [
|
||||
"operation_submitted",
|
||||
"approval_recorded",
|
||||
"approval_recorded",
|
||||
]
|
||||
assert [event.status_after for event in distinct_events] == [
|
||||
"pending_approval",
|
||||
"pending_approval",
|
||||
"approved",
|
||||
]
|
||||
assert {event.trace_id for event in distinct_events[1:]} == set(distinct_traces)
|
||||
_assert_event_integrity(distinct_events)
|
||||
|
||||
rejection_request = _submission_request(repository, "pg-rejection")
|
||||
with _trace("trace-rejection-submit"):
|
||||
rejection_operation = _submit(repository, rejection_request).item
|
||||
with _trace("trace-rejection"):
|
||||
rejected = repository.decide(
|
||||
rejection_request.operation_id,
|
||||
PrivilegedIdentityOperationApprovalRequest(
|
||||
decision="reject",
|
||||
comment="PostgreSQL rejection terminal-state verification",
|
||||
preview_hash=rejection_operation.preview_hash,
|
||||
),
|
||||
actor_tenant_id=_TENANT_ID,
|
||||
actor_user_id="user-approver-one",
|
||||
)
|
||||
assert rejected.status == "rejected"
|
||||
with pytest.raises(PrivilegedIdentityOperationError) as terminal_error:
|
||||
_approve(
|
||||
repository,
|
||||
rejection_request.operation_id,
|
||||
rejection_operation.preview_hash,
|
||||
"user-approver-two",
|
||||
)
|
||||
assert terminal_error.value.code == "invalid_operation_state"
|
||||
rejection_events = _events(session_factory, rejection_request.operation_id)
|
||||
assert [event.event_type for event in rejection_events] == [
|
||||
"operation_submitted",
|
||||
"operation_rejected",
|
||||
]
|
||||
assert rejection_events[1].trace_id == "trace-rejection"
|
||||
_assert_event_integrity(rejection_events)
|
||||
|
||||
expiry_request = _submission_request(repository, "pg-expiry")
|
||||
with _trace("trace-expiry-submit"):
|
||||
_submit(repository, expiry_request)
|
||||
with session_factory.begin() as session:
|
||||
expiring = session.get(PrivilegedIdentityOperationModel, expiry_request.operation_id)
|
||||
assert expiring is not None
|
||||
expiring.expires_at = datetime.now(UTC) - timedelta(seconds=1)
|
||||
with _trace("trace-expiry"):
|
||||
expired = repository.get_operation(
|
||||
expiry_request.operation_id,
|
||||
actor_tenant_id=_TENANT_ID,
|
||||
actor_user_id="user-requester",
|
||||
)
|
||||
repository.get_operation(
|
||||
expiry_request.operation_id,
|
||||
actor_tenant_id=_TENANT_ID,
|
||||
actor_user_id="user-requester",
|
||||
)
|
||||
assert expired is not None
|
||||
assert expired.status == "expired"
|
||||
expiry_events = _events(session_factory, expiry_request.operation_id)
|
||||
assert [event.event_type for event in expiry_events] == [
|
||||
"operation_submitted",
|
||||
"operation_expired",
|
||||
]
|
||||
assert expiry_events[1].trace_id == "trace-expiry"
|
||||
_assert_event_integrity(expiry_events)
|
||||
|
||||
with session_factory.begin() as session:
|
||||
users = session.execute(select(TenantUserModel)).scalars().all()
|
||||
for user in users:
|
||||
if user.id != "user-requester":
|
||||
user.platform_permissions = [
|
||||
permission
|
||||
for permission in (user.platform_permissions or [])
|
||||
if permission != "platform_admin"
|
||||
]
|
||||
operation_count_before = session.scalar(select(func.count(PrivilegedIdentityOperationModel.operation_id)))
|
||||
with pytest.raises(PrivilegedIdentityOperationError) as final_admin_error:
|
||||
repository.preview(
|
||||
PrivilegedIdentityOperationPreviewRequest(
|
||||
operation_type="platform_admin_revoke",
|
||||
target_refs={
|
||||
"target_tenant_id": _TENANT_ID,
|
||||
"target_user_id": "user-requester",
|
||||
},
|
||||
evidence_refs=[],
|
||||
)
|
||||
)
|
||||
assert final_admin_error.value.code == "final_platform_administrator"
|
||||
with session_factory() as session:
|
||||
operation_count_after = session.scalar(select(func.count(PrivilegedIdentityOperationModel.operation_id)))
|
||||
assert operation_count_after == operation_count_before
|
||||
|
||||
print(
|
||||
"PostgreSQL concurrency evidence: "
|
||||
"idempotent_submit=1 operation/1 event; "
|
||||
"same_approver=1 approval/1 decision event; "
|
||||
"distinct_approvers=2 approvals/ordered status approval; "
|
||||
"rejection=terminal/unique; expiry=materialized_once; "
|
||||
"trace_and_snapshot_hashes=verified; final_admin=protected"
|
||||
)
|
||||
Reference in New Issue
Block a user