Skip to content
Merged
15 changes: 13 additions & 2 deletions features/v3.0.0-beta1/048-rebac-permission-model-grants/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1041,7 +1041,17 @@ HTTP 每次最多 50 个 change item;单槽 visible tuple 也计入编译后
FGA Write 超时属于 `COMMIT_UNKNOWN`:reconciler 用 higher consistency 对比 operation
记录的 before/after tuple checksum。全为 after 则转 COMMITTED,全为 before 才可按原
operation 重试;出现混合集或 scope version 已被外部改变时标 FAILED_CLOSED 并 fence
该资源,不能盲目补写覆盖后来状态。
该资源的后续权限配置写入,不能盲目补写覆盖后来状态。资源的具体 action/visible 决策不复用
该写栅栏:`PROJECTING`、`COMMIT_UNKNOWN`、`COMMITTED` 和 `FAILED_CLOSED` 期间仍由唯一
OpenFGA 执行面返回 ALLOW/DENY,并强制 higher consistency。OpenFGA 或 CURRENT Catalog/model
不可用、verified identity/parent 不匹配时仍 fail closed;SQL Grant 与待处理来源不参与决策兜底。

`ResourcePermissionMode.projection_state` 同时承担 operation 归属和版本提交记录,但不再作为
普通业务鉴权的全局停服开关。decision fence 接受 `CURRENT/PROJECTING/FAILED_CLOSED` 三种资源
镜像状态;后两者以及同一请求内观察到的 permission version 变化返回 degraded 标记并强制
higher consistency。Grant/mode/lifecycle command 仍只允许从 `CURRENT` claim 新 operation,
因此同一资源的权限写保持串行。`my-permissions` 在 degraded 状态只返回 OpenFGA 逐 action
结果和投影状态,不读取 staged SQL source explanation。

人工恢复统一从 backend 容器运行
`scripts/reconcile_f048_projection_operations.py --tenant-id <tenant> <operation...>`;默认
Expand Down Expand Up @@ -1872,7 +1882,7 @@ D5 必须同时满足:
| D2 数据脚本 | 保持运维停流;修复源数据/映射或脚本后,以同一 run/checkpoint 续跑 `migrate --apply` |
| D3~D4 | 保持运维停流;按 migration item 对 SQL/同一 Store 的新 tuple 与 legacy delete 做幂等前向修正,重跑完整 D4 |
| D5 重启或 smoke | 立即保持/恢复运维停流并停止全部权限写;新 model 仍是唯一目标,修复配置/代码/投影后重跑 D4+D5 |
| D6 运行中 | 由新业务 Service + `permission_projection_operation/tuple` 执行 forward-fix;`FAILED_CLOSED` 资源保持 fenced,修复并 higher-consistency 验证后再开放 |
| D6 运行中 | 由新业务 Service + `permission_projection_operation/tuple` 执行 forward-fix;`FAILED_CLOSED` 资源冻结新的权限配置写入,但具体 action/visible 继续以 higher-consistency OpenFGA 决策;修复验证后恢复正常一致性与权限写入 |

禁止重新 pin 旧 model、恢复 Config 第二 PDP、把新授权 down-convert 成四档 tuple、
逐请求询问旧 model,或只回退应用代码。旧 model 无法从 OpenFGA 删除,保留它只是产品
Expand Down Expand Up @@ -1934,6 +1944,7 @@ D3 已完成全部旧运行数据退役,D6 没有延后的 cleanup 窗口。
|---|---|---|
| 2026-08-13 | 对单槽浅层 visible、inactive 既有授权保持、删除零引用门禁、旧系统单次迁移、列表路径、契约/依赖/测试/可观测执行 24 项 Design 接手测试与 Constitution Check;复审 LGTM,停在 Design ★ | `/sdd-review ... design` |
| 2026-08-13 | 将模型 `active` 收窄为“是否可用于新增/变更授权”:停用不影响已有 Grant;删除必须先撤销或替换全部绑定,并在引用/source projection/live tuple 清零后完成。可见执行关系改为单槽浅层 `visible`,移除 A/B 槽、switch、双写和 Catalog 4-tuple 切换;保留来源引用计数、ledger、reconcile 与旧系统单次迁移 | 用户确认界面语义“关闭后不能再用它授权,已有授权不受影响;删除必须先清理绑定关系” |
| 2026-08-20 | 将资源权限投影状态从普通鉴权停服条件中解耦:非 CURRENT 期间继续执行 higher-consistency OpenFGA action/visible 决策,只冻结同资源新的权限配置写;`my-permissions` 降级为 OpenFGA 逐动作结果且不展示 staged SQL 来源 | 用户确认增加查看者等权限修改不得影响已有授权的正常鉴权 |
| 2026-08-13 | 按 F048 未上线事实重新完成 24 项 Design 接手测试:旧系统单次迁移、A/B 可见投影、订阅/历史 membership 证据边界、D4 完整性门禁和 Release Contract 均一致;复审 LGTM,停在 Design ★ | `/sdd-review ... design` |
| 2026-08-13 | 用户澄清 F048 尚未上线:删除“旧 F048 → successor model”二次迁移和独立 migration run/checkpoint,把 A/B 可见 source projection、switch 与完整性校验合并到原 `migrate_f048_permission_data.py` 从旧系统执行的唯一正式 run | 用户迁移拓扑纠正 |
| 2026-08-13 | 首轮 24 项 Design 接手测试修正现状快照、跨模型同来源 fingerprint 冲突与完整枚举错误码缺口;迁移拓扑后续按上一行重新修订并复审 | `/sdd-review ... design` |
Expand Down
12 changes: 12 additions & 0 deletions features/v3.0.0-beta1/048-rebac-permission-model-grants/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,18 @@
- **AC-68** — WHEN 两个管理员基于同一旧版本并发修改模型、Grant 或权限模式, THE SYSTEM SHALL 只接受符合当前版本的更新,并明确拒绝过期覆盖。
- **AC-69** — WHEN 授权或撤销成功返回, THE SYSTEM SHALL 让随后用于安全决策的读取观察到该新状态,不得因旧缓存继续产生与已确认变更相反的 ALLOW。
- **AC-70** — IF 业务数据变更已发生但权限状态未能完成一致更新, THEN THE SYSTEM SHALL 不把该权限变化报告为已生效,并产生可恢复、可审计的异常;业务资源结果继续遵守其 Owner Feature 与 Constitution C4 的失败补偿契约。
- **AC-178** — WHILE 一个已存在资源的权限投影处于 `PROJECTING`、`COMMIT_UNKNOWN`、
`COMMITTED` 或 `FAILED_CLOSED`, THE SYSTEM SHALL 继续通过唯一 OpenFGA 执行面对该资源执行
具体 action、`visible` 与批量鉴权;投影控制面状态本身不得暂停未参与本次变更的既有授权。
- **AC-179** — WHEN 非 `CURRENT` 投影状态或权限版本在同一请求内发生切换, THE SYSTEM SHALL
对具体资源鉴权强制使用 higher consistency;OpenFGA、CURRENT Catalog/model 或已验证资源
identity/parent 不可用时仍须 fail closed,不得使用 SQL Grant 或待处理记录补充 ALLOW。
- **AC-180** — WHILE 资源权限投影不是 `CURRENT`, THE SYSTEM SHALL 拒绝新的 Grant mutation、
mode switch 及其他权限配置写入,只允许原 operation 的幂等继续或受控恢复;普通资源业务
读写不得复用该权限写锁。
- **AC-181** — WHEN `my-permissions` 在非 `CURRENT` 投影状态下读取当前用户有效权限,
THE SYSTEM SHALL 从 OpenFGA 逐动作计算结果并返回投影降级状态,不得把 SQL 中的
`PENDING`、`PENDING_DELETE` 或 `PROJECTING` 来源明细包装成最终授权来源。

### 3.9 PRD 显式交互与新建规则

Expand Down
26 changes: 26 additions & 0 deletions features/v3.0.0-beta1/048-rebac-permission-model-grants/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -1631,6 +1631,32 @@

---

---

## Wave 15 — 投影写锁与普通鉴权解耦

- [x] **T195:非 CURRENT 投影鉴权合同测试**
- **文件**:`src/backend/test/permission/test_stale_projection_fail_soft.py`,
`src/backend/test/permission/test_f048_resource_api.py`
- **测试**:覆盖 `CURRENT/PROJECTING/FAILED_CLOSED` decision fence、请求内 version 切换、
higher-consistency 强制、PENDING 拒绝、FAILED_CLOSED 新权限写拒绝,以及 degraded
`my-permissions` 不读取 staged source explanation。
- **覆盖 AC**:AC-178, AC-179, AC-180, AC-181

- [x] **T196:解耦资源投影写锁与 OpenFGA 决策路径**
- **文件**:`src/backend/bisheng/permission/domain/models/grant.py`,
`src/backend/bisheng/permission/application/control_state.py`,
`src/backend/bisheng/permission/application/sql_runtime.py`,
`src/backend/bisheng/permission/domain/services/permission_action_service.py`,
`src/backend/bisheng/permission/application/resource_api.py`
- **逻辑**:允许现有资源在 `PROJECTING/FAILED_CLOSED` 镜像状态下生成 verified target 并执行
OpenFGA action/visible;非 CURRENT 或版本切换强制 higher consistency;Grant/mode/lifecycle
新 operation 仍只从 CURRENT claim;degraded `my-permissions` 返回实际 action 与投影状态,
不把 PENDING/PENDING_DELETE 来源作为最终事实。
- **验收**:T195、F048 permission service、projection SQL runtime 定向回归、Ruff、
arch-guard 与 diff-check 通过。
- **依赖**:T195

## 实际偏差记录

> 只记录一句话指针;设计原因和反直觉事实回写 [design.md](./design.md)。
Expand Down
3 changes: 2 additions & 1 deletion features/v3.0.0-beta1/release-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
| INV-16 | 权限继承复用资源既有的直接 `parent` 语义,不建立第二套 `permission_parent` 层级;`CUSTOM` 只切断权限继承,不能改变业务结构父子关系 | ResourcePermissionMode | F048 |
| INV-17 | 任一有效 Grant 可以产生资源列表/基础元数据可见性,但可见性不能替代下载、搜索、RAG 或业务变更动作的具体鉴权。文件预览不设置 PermissionAction;只有原件/打包下载必须检查 `download`,不得由“可预览”推导下载能力 | PermissionAction, PermissionGrant | F048 |
| INV-18 | 权限升级采用应用自动阻断业务访问后的单向正式数据迁移:更新镜像并启动进程后,旧 model 只能进入 `MIGRATION_REQUIRED/NOT_READY` 运维态,不初始化 F048 权限运行时、不发布 ready heartbeat,HTTP/WS 迁移门禁除 `/health` 外统一拒绝访问,Celery/Linsight 暂停消费任务;schema upgrade 成功后,由 `src/backend/scripts/` 专用脚本沿用现有 Store 发布一个新 model ID,原地转换 tuple 并退役旧运行数据,校验通过后重启全部进程并自动恢复访问/任务消费。F048 不提供独立迁移预演、旧/新 model 影子运行、应用级回滚、新→旧转换、dual/legacy model client、长期双写、旧动作别名、Config 第二 PDP 或逐请求旧系统 ALLOW fallback;失败保持维护并前向修复 | PermissionMigrationRun | F048 |
| INV-19 | 对需要进入资源 ReBAC 的请求,权限服务不可用、模型未生效、动作未分级、迁移记录不明确或授权状态不可判定时必须 fail closed | PermissionAction, PermissionModel, PermissionGrant | F048 |
| INV-19 | 对需要进入资源 ReBAC 的请求,权限服务不可用、模型未生效、动作未分级、迁移记录不明确或 OpenFGA 具体决策不可获得时必须 fail closed;资源投影处于 PROJECTING/COMMIT_UNKNOWN/COMMITTED/FAILED_CLOSED 本身不等于具体决策不可判定,普通 action/visible 继续通过唯一 OpenFGA 执行面以 higher consistency 决策,但新的权限配置写持续冻结且 SQL Grant/待处理来源不得补充 ALLOW | PermissionAction, PermissionModel, PermissionGrant | F048 |
| INV-20 | 动作、模型、模型动作、资源 Grant、Grant 主体和权限模式的运行时事实必须存于规范化关系表;`permission_relation_models_v1`、`permission_relation_model_bindings_v1` 及任何新的大 JSON 不得继续作为运行时真相 | PermissionAction, PermissionModel, PermissionGrant, ResourcePermissionMode | F048 |
| INV-21 | 所有生产 OpenFGA Check、List 和 Write 必须显式指定经发布门禁确认的 Authorization Model ID;发布新模型不得依赖“自动使用最新模型”完成切换 | AuthorizationModelRelease | F048 |
| INV-22 | 旧 `owner/manager/editor/viewer` 只迁移直接关系事实;由旧模型计算出的层级、父级或角色蕴含结果不得展开为新的 Grant assignee | PermissionGrant, PermissionGrantAssignee | F048 |
Expand Down Expand Up @@ -144,3 +144,4 @@
| 2026-08-13 | 明确模型停用/删除语义:停用只禁止新增或变更授权,已有 Grant 保持有效;删除必须先清零或替换全部绑定并完成残留投影对账。因停用不再触发批量撤权,F048 可见执行投影采用单槽浅层 `visible`,不引入 A/B 槽与运行时 switch | F048 |
| 2026-08-14 | 登记 F049 知识空间目录与搜索读取优化:指定资源的超级管理员按 C4 系统身份策略放行、去重空间鉴权、页大小驱动的有界候选扫描、移除未展示的文件夹数量统计并保留失败存在性、增加分段性能观测;普通用户候选最终可见性继续统一使用 OpenFGA BatchCheck,不新增继承捷径 | F049、F027、F040、F048 |
| 2026-08-14 | 登记 F050 统一权限设置入口:保留 v2.6.0 F044 页面目标,创建/编辑权限完全改接 F048,并增加创建后初始 Grant 部分失败与持久幂等约束 INV-28 | F050、F048 |
| 2026-08-20 | 澄清 INV-19 的决策与写入边界:资源权限投影非 CURRENT 时冻结新的权限配置写,但不暂停普通资源鉴权;具体 action/visible 继续使用 higher-consistency OpenFGA,OpenFGA/Catalog/model/verified identity 不可用时仍 fail closed,SQL 不兜底 ALLOW | F048 |
7 changes: 6 additions & 1 deletion src/backend/bisheng/permission/api/endpoints/decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,16 @@ async def check_permission_action(
except ValidationError:
return InvalidCatalogActionError.return_resp()
try:
actor = await permission_actor(login_user)
if request.action == "visible" and actor.super_admin:
# This HTTP contract answers privileged operational access. Keep the
# domain visible Check/BatchCheck/ListObjects relation unchanged.
return resp_200({"allowed": True})
allowed = await api.check(
resource_type=request.resource_type,
resource_id=request.resource_id,
action=request.action,
actor=await permission_actor(login_user),
actor=actor,
)
except BaseErrorCode as error:
return permission_error_response(error)
Expand Down
44 changes: 43 additions & 1 deletion src/backend/bisheng/permission/application/control_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
stable_grant_key,
)
from bisheng.permission.domain.models import (
DECIDABLE_PROJECTION_STATES,
AuthorizationModelRelease,
PermissionAction,
PermissionCatalogRelease,
Expand Down Expand Up @@ -200,7 +201,7 @@ async def permission_version(
ResourcePermissionMode.resource_id == resource_id,
)
row = (await session.execute(statement)).scalars().first()
if row is None or row.projection_state != "CURRENT":
if row is None or row.projection_state not in DECIDABLE_PROJECTION_STATES:
raise PermissionPublishNotReadyError(msg="Resource permission projection is not current")
context = "|".join(
(
Expand Down Expand Up @@ -1283,6 +1284,47 @@ async def _upsert_assignee(
.scalars()
.first()
)
if row is not None and row.id != source.source_id:
signature = (
row.subject_type,
row.subject_id,
row.userset_relation,
row.source_locator,
bool(row.protected),
)
expected = (
source.subject_type,
source.subject_id,
source.userset_relation,
source.source_locator,
source.protected,
)
if signature != expected:
raise PermissionVersionConflictError(msg="Permission source fingerprint collision")
if row.state != "INACTIVE":
raise PermissionVersionConflictError(
msg="Target Grant already contains another current assignee identity",
)
visible_owner_key = f"grant_assignee:{row.id}"
current_visible_source = (
await session.execute(
select(PermissionVisibleSourceProjection.id)
.where(
PermissionVisibleSourceProjection.tenant_id == grant_row.tenant_id,
PermissionVisibleSourceProjection.source_kind == "GRANT_ASSIGNEE",
PermissionVisibleSourceProjection.source_owner_key == visible_owner_key,
PermissionVisibleSourceProjection.state != "RETIRED",
)
.limit(1)
)
).scalar_one_or_none()
if current_visible_source is not None:
raise PermissionVersionConflictError(
msg="Historical target assignee still owns a current visible projection",
)
await session.delete(row)
await session.flush()
row = None
if row is None:
id_collision = await session.get(
PermissionGrantAssignee,
Expand Down
Loading
Loading