From 1fbbadf08feaa8cef17b9f6f155c3cc583b3bf9b Mon Sep 17 00:00:00 2001
From: dolphin
Date: Mon, 10 Aug 2026 23:03:55 +0800
Subject: [PATCH 01/38] fix(permission): carry grant assignee ids as strings so
revoke can find them
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
撤销和改权限模型在 116 上必然失败,报「Grant assignee is missing or ambiguous」。
授权行的 id 是 60~62 位整数(API 新增走 secrets.randbits(62),所有者/复制路径走
sha256[:15]),超过 JSON number 在浏览器里能表示的 2^53。以数字下发时 JSON.parse
当场四舍五入,前端原样回传的 id 与任何一条 assignee 都对不上,_find_source 找到 0
条即抛错。实测约 99% 的 id 会失真,也就是基本必挂。ADD 不受影响,它按主体识别而不
是按行 id。
只把 id 挪到线上的十进制字符串:领域层、仓储和 BigInteger 列仍是整数,无需数据迁移。
入参改为正整数字符串并拒绝数字形态——混合部署下宁可显式报参数错,也不要重新掉进
静默截断。新增 test_f048_assignee_id_wire_format.py 覆盖两种分配器的上界值。
顺带修掉 client 端的撤销确认弹窗:原本是手搓的 AlertDialog,确认按钮取顶层 key
`confirm`,而三个语言包都没有这个 key,i18next 直接把 key 当文案渲染成英文
"confirm"。改用共享的 useConfirm() destructive 变体,与会话删除等危险操作一致,并在
正文里点名被撤销的主体——原来只问「确认撤销该主体吗」,不告诉你是哪个。
已知未处理:撤销成功后 grants:mutate 的返回体把 scope 写死 LOCAL、主体名置空、模型
名退化成 model_key,而前端拿它整体替换了列表,成功后剩余行会短暂「变糙」,刷新恢复。
此前撤销从未成功,所以这个问题一直没暴露。
Co-Authored-By: Claude Opus 5 (1M context)
---
.../permission/application/resource_api.py | 6 +-
.../bisheng/permission/domain/schemas/f048.py | 10 +-
.../test_f048_assignee_id_wire_format.py | 101 ++++++++++++++++++
.../test/permission/test_f048_grant_api.py | 4 +-
.../client/src/api/permission.test.ts | 4 +-
src/frontend/client/src/api/permission.ts | 6 +-
.../permission/PermissionGrantTab.test.tsx | 12 +--
.../permission/PermissionGrantTab.tsx | 4 +-
.../permission/PermissionListTab.test.tsx | 8 +-
.../permission/PermissionListTab.tsx | 58 +++-------
.../bs-comp/permission/PermissionListTab.tsx | 2 +-
.../src/controllers/API/permission.ts | 6 +-
.../src/test/f048PermissionApi.test.ts | 4 +-
.../src/test/f048PermissionDialog.test.tsx | 2 +-
.../src/test/f048PermissionGrantTab.test.tsx | 8 +-
.../src/test/f048PermissionRoster.test.tsx | 4 +-
16 files changed, 160 insertions(+), 79 deletions(-)
create mode 100644 src/backend/test/permission/test_f048_assignee_id_wire_format.py
diff --git a/src/backend/bisheng/permission/application/resource_api.py b/src/backend/bisheng/permission/application/resource_api.py
index 270cf56876..90c929b167 100644
--- a/src/backend/bisheng/permission/application/resource_api.py
+++ b/src/backend/bisheng/permission/application/resource_api.py
@@ -183,7 +183,7 @@ async def list_grants(
model_names = {item.snapshot.model_key: item.name for item in catalog.models}
data = [
{
- "assignee_id": row.source_id,
+ "assignee_id": str(row.source_id),
"assignee_version": row.source_version,
"subject": {
"type": row.subject_type,
@@ -294,7 +294,7 @@ async def mutate_grants(
operation=change.op.value,
model_key=change.model_key,
source=source,
- assignee_id=change.assignee_id,
+ assignee_id=change.assignee_row_id,
expected_assignee_version=(change.expected_assignee_version),
target_model_key=change.target_model_key,
)
@@ -309,7 +309,7 @@ async def mutate_grants(
)
items = [
{
- "assignee_id": source.source_id,
+ "assignee_id": str(source.source_id),
"assignee_version": source.version,
"subject": {
"type": source.subject_type,
diff --git a/src/backend/bisheng/permission/domain/schemas/f048.py b/src/backend/bisheng/permission/domain/schemas/f048.py
index 90172d0eba..53b97736eb 100644
--- a/src/backend/bisheng/permission/domain/schemas/f048.py
+++ b/src/backend/bisheng/permission/domain/schemas/f048.py
@@ -225,10 +225,16 @@ class GrantMutationChange(StrictRequestModel):
op: GrantMutationOperation
model_key: str | None = Field(default=None, max_length=64)
subject: GrantSubjectInput | None = None
- assignee_id: int | None = Field(default=None, gt=0)
+ # Carried as a decimal string: row ids are 60-62 bit, past the 2^53 that a
+ # JSON number survives in a browser, and a rounded id matches no assignee.
+ assignee_id: str | None = Field(default=None, pattern=r"^[1-9][0-9]{0,19}$")
expected_assignee_version: int | None = Field(default=None, ge=0)
target_model_key: str | None = Field(default=None, max_length=64)
+ @property
+ def assignee_row_id(self) -> int | None:
+ return None if self.assignee_id is None else int(self.assignee_id)
+
@model_validator(mode="after")
def validate_operation_shape(self) -> GrantMutationChange:
if self.op == GrantMutationOperation.ADD:
@@ -279,7 +285,7 @@ class GrantSourceDTO(BaseModel):
class GrantAssigneeDTO(BaseModel):
- assignee_id: int
+ assignee_id: str
assignee_version: int
subject: GrantSubjectDTO
model: GrantModelDTO
diff --git a/src/backend/test/permission/test_f048_assignee_id_wire_format.py b/src/backend/test/permission/test_f048_assignee_id_wire_format.py
new file mode 100644
index 0000000000..e8f0eb66e3
--- /dev/null
+++ b/src/backend/test/permission/test_f048_assignee_id_wire_format.py
@@ -0,0 +1,101 @@
+"""Assignee row ids must cross the wire as strings.
+
+Row ids are allocated as 60-62 bit integers (``secrets.randbits(62)`` for API
+adds, ``sha256(...)[:15]`` for owner/copy paths), well past the 2^53 a JSON
+number survives in a browser. Sent as a number, ``JSON.parse`` silently rounds
+it and the id the client echoes back matches no assignee, so every REMOVE/MOVE
+fails with "Grant assignee is missing or ambiguous".
+"""
+
+import json
+
+import pytest
+from pydantic import ValidationError
+
+from bisheng.permission.domain.schemas.f048 import (
+ GrantAssigneeDTO,
+ GrantMutationChange,
+)
+
+# Both allocators produce ids in this range; 2**53 is the JS safe-integer limit.
+UNSAFE_IDS = (
+ 2**53 + 1,
+ 4_611_686_018_427_387_903, # secrets.randbits(62) upper bound
+ 1_152_921_504_606_846_975, # int(sha256(...)[:15], 16) upper bound
+)
+
+
+@pytest.mark.parametrize("row_id", UNSAFE_IDS)
+def test_remove_change_preserves_ids_beyond_js_safe_integers(row_id: int) -> None:
+ change = GrantMutationChange.model_validate(
+ {
+ "op": "REMOVE",
+ "assignee_id": str(row_id),
+ "expected_assignee_version": 2,
+ }
+ )
+ assert change.assignee_id == str(row_id)
+ assert change.assignee_row_id == row_id
+
+
+def test_numeric_assignee_id_is_rejected() -> None:
+ """A number is the shape that loses precision in the browser."""
+
+ with pytest.raises(ValidationError):
+ GrantMutationChange.model_validate(
+ {
+ "op": "REMOVE",
+ "assignee_id": 91,
+ "expected_assignee_version": 2,
+ }
+ )
+
+
+@pytest.mark.parametrize("bad", ["0", "-1", "01", "9e18", "", "12a"])
+def test_assignee_id_must_be_a_positive_decimal_string(bad: str) -> None:
+ with pytest.raises(ValidationError):
+ GrantMutationChange.model_validate(
+ {
+ "op": "REMOVE",
+ "assignee_id": bad,
+ "expected_assignee_version": 2,
+ }
+ )
+
+
+def test_add_still_rejects_an_assignee_identity() -> None:
+ with pytest.raises(ValidationError):
+ GrantMutationChange.model_validate(
+ {
+ "op": "ADD",
+ "model_key": "standard-viewer",
+ "subject": {"type": "user", "id": "7"},
+ "assignee_id": "91",
+ }
+ )
+
+
+@pytest.mark.parametrize("row_id", UNSAFE_IDS)
+def test_roster_row_serializes_the_id_as_a_json_string(row_id: int) -> None:
+ dto = GrantAssigneeDTO.model_validate(
+ {
+ "assignee_id": str(row_id),
+ "assignee_version": 2,
+ "subject": {"type": "user", "id": "7", "name": "Alice"},
+ "model": {
+ "key": "standard-viewer",
+ "name": "Viewer",
+ "level": 1,
+ "active": True,
+ },
+ "source": {"type": "DIRECT", "include_children": False},
+ "scope": "LOCAL",
+ "inherited_from": None,
+ "protected": False,
+ "editable": True,
+ }
+ )
+ payload = json.loads(dto.model_dump_json())
+ assert payload["assignee_id"] == str(row_id)
+ # Quoted in the raw JSON, so a browser never parses it as a number.
+ assert f'"assignee_id":"{row_id}"' in dto.model_dump_json()
diff --git a/src/backend/test/permission/test_f048_grant_api.py b/src/backend/test/permission/test_f048_grant_api.py
index 8f9c2f6a94..5f79d81a1c 100644
--- a/src/backend/test/permission/test_f048_grant_api.py
+++ b/src/backend/test/permission/test_f048_grant_api.py
@@ -50,7 +50,7 @@ async def list_grants(self, **kwargs) -> dict:
return {
"data": [
{
- "assignee_id": 91,
+ "assignee_id": "91",
"assignee_version": 2,
"subject": {
"type": "department",
@@ -190,7 +190,7 @@ def test_protected_mutation_and_stale_cursor_preserve_business_codes() -> None:
"changes": [
{
"op": "REMOVE",
- "assignee_id": 91,
+ "assignee_id": "91",
"expected_assignee_version": 2,
}
],
diff --git a/src/frontend/client/src/api/permission.test.ts b/src/frontend/client/src/api/permission.test.ts
index 255c1731c1..651715a348 100644
--- a/src/frontend/client/src/api/permission.test.ts
+++ b/src/frontend/client/src/api/permission.test.ts
@@ -86,13 +86,13 @@ describe("F048 Client permission API", () => {
},
{
op: "MOVE" as const,
- assignee_id: 8,
+ assignee_id: "8",
expected_assignee_version: 3,
target_model_key: "standard-editor",
},
{
op: "REMOVE" as const,
- assignee_id: 9,
+ assignee_id: "9",
expected_assignee_version: 4,
},
],
diff --git a/src/frontend/client/src/api/permission.ts b/src/frontend/client/src/api/permission.ts
index 80e13600ae..7dcd39cd50 100644
--- a/src/frontend/client/src/api/permission.ts
+++ b/src/frontend/client/src/api/permission.ts
@@ -45,7 +45,7 @@ export interface PermissionGrantSource {
}
export interface PermissionGrantAssignee {
- assignee_id: number;
+ assignee_id: string;
assignee_version: number;
subject: PermissionGrantSubject;
model: GrantablePermissionModel;
@@ -85,13 +85,13 @@ export type PermissionGrantMutationChange =
}
| {
op: "MOVE";
- assignee_id: number;
+ assignee_id: string;
expected_assignee_version: number;
target_model_key: string;
}
| {
op: "REMOVE";
- assignee_id: number;
+ assignee_id: string;
expected_assignee_version: number;
};
diff --git a/src/frontend/client/src/components/permission/PermissionGrantTab.test.tsx b/src/frontend/client/src/components/permission/PermissionGrantTab.test.tsx
index ac5edbc029..881f2b7f1d 100644
--- a/src/frontend/client/src/components/permission/PermissionGrantTab.test.tsx
+++ b/src/frontend/client/src/components/permission/PermissionGrantTab.test.tsx
@@ -79,7 +79,7 @@ const context: ResourcePermissionContext = {
};
function existing(
- id: number,
+ id: string,
overrides: Partial = {},
): PermissionGrantAssignee {
return {
@@ -155,7 +155,7 @@ describe("F048 Client PermissionGrantTab", () => {
resourceType="channel"
resourceId="channel-1"
context={context}
- assignees={[existing(1), existing(2)]}
+ assignees={[existing("1"), existing("2")]}
onSuccess={jest.fn()}
/>,
);
@@ -225,8 +225,8 @@ describe("F048 Client PermissionGrantTab", () => {
resourceId="channel-1"
context={context}
assignees={[
- existing(3, { protected: true, editable: false }),
- existing(4, { scope: "INHERITED", editable: false }),
+ existing("3", { protected: true, editable: false }),
+ existing("4", { scope: "INHERITED", editable: false }),
]}
onSuccess={jest.fn()}
/>,
@@ -251,7 +251,7 @@ describe("F048 Client PermissionGrantTab", () => {
resourceType="channel"
resourceId="channel-1"
context={context}
- assignees={[existing(99)]}
+ assignees={[existing("99")]}
onSuccess={jest.fn()}
/>,
);
@@ -319,7 +319,7 @@ describe("F048 Client PermissionGrantTab", () => {
resourceType="channel"
resourceId="channel-1"
context={context}
- assignees={[existing(1)]}
+ assignees={[existing("1")]}
onSuccess={jest.fn()}
/>,
);
diff --git a/src/frontend/client/src/components/permission/PermissionGrantTab.tsx b/src/frontend/client/src/components/permission/PermissionGrantTab.tsx
index 04d7e868fe..97bbe3d640 100644
--- a/src/frontend/client/src/components/permission/PermissionGrantTab.tsx
+++ b/src/frontend/client/src/components/permission/PermissionGrantTab.tsx
@@ -79,8 +79,8 @@ export function PermissionGrantTab({
);
const [selectedModelKey, setSelectedModelKey] = useState("");
const [internalIncludeChildren, setInternalIncludeChildren] = useState(false);
- const [targetModels, setTargetModels] = useState>({});
- const [removedIds, setRemovedIds] = useState>(new Set());
+ const [targetModels, setTargetModels] = useState>({});
+ const [removedIds, setRemovedIds] = useState>(new Set());
const [queuedAdds, setQueuedAdds] = useState<
PermissionGrantMutationChange[]
>([]);
diff --git a/src/frontend/client/src/components/permission/PermissionListTab.test.tsx b/src/frontend/client/src/components/permission/PermissionListTab.test.tsx
index 9dbc341139..a71eec0942 100644
--- a/src/frontend/client/src/components/permission/PermissionListTab.test.tsx
+++ b/src/frontend/client/src/components/permission/PermissionListTab.test.tsx
@@ -48,7 +48,7 @@ const context: ResourcePermissionContext = {
};
function assignee(
- id: number,
+ id: string,
source: "DIRECT" | "DEPARTMENT",
overrides: Partial = {},
): PermissionGrantAssignee {
@@ -72,7 +72,7 @@ describe("F048 Client PermissionListTab", () => {
{ key: "standard-viewer", name: "Viewer", level: 1, active: true },
]);
mockedGetGrants.mockResolvedValue({
- data: [assignee(1, "DIRECT"), assignee(2, "DEPARTMENT")],
+ data: [assignee("1", "DIRECT"), assignee("2", "DEPARTMENT")],
page_size: 2,
has_more: true,
next_cursor: "cursor-2",
@@ -112,7 +112,7 @@ describe("F048 Client PermissionListTab", () => {
mockedGetGrants
.mockResolvedValueOnce({
data: [
- assignee(3, "DIRECT", {
+ assignee("3", "DIRECT", {
protected: true,
editable: false,
scope: "INHERITED",
@@ -124,7 +124,7 @@ describe("F048 Client PermissionListTab", () => {
next_cursor: "cursor-2",
})
.mockResolvedValueOnce({
- data: [assignee(4, "DIRECT", { subject: { type: "user", id: "8", name: "Bob" } })],
+ data: [assignee("4", "DIRECT", { subject: { type: "user", id: "8", name: "Bob" } })],
page_size: 1,
has_more: false,
next_cursor: null,
diff --git a/src/frontend/client/src/components/permission/PermissionListTab.tsx b/src/frontend/client/src/components/permission/PermissionListTab.tsx
index 37d920804b..8cc7f46df5 100644
--- a/src/frontend/client/src/components/permission/PermissionListTab.tsx
+++ b/src/frontend/client/src/components/permission/PermissionListTab.tsx
@@ -24,17 +24,9 @@ import type {
ResourceType,
SubjectType,
} from "~/api/permission";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- Button,
-} from "~/components/ui";
+import { Button } from "~/components/ui";
import { useLocalize } from "~/hooks";
+import { useConfirm } from "~/Providers";
import { SourceBadge } from "./SourceBadge";
interface PermissionListTabProps {
@@ -223,6 +215,7 @@ export function PermissionListTab({
onMutationSuccess,
}: PermissionListTabProps) {
const localize = useLocalize();
+ const confirm = useConfirm();
const [assignees, setAssignees] = useState([]);
const [models, setModels] = useState([]);
const [summary, setSummary] = useState(null);
@@ -231,11 +224,9 @@ export function PermissionListTab({
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
- const [pendingAssigneeId, setPendingAssigneeId] = useState(
+ const [pendingAssigneeId, setPendingAssigneeId] = useState(
null,
);
- const [removeTarget, setRemoveTarget] =
- useState(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
@@ -382,10 +373,20 @@ export function PermissionListTab({
setFailed(true);
} finally {
setPendingAssigneeId(null);
- setRemoveTarget(null);
}
};
+ const handleRemove = async (assignee: PermissionGrantAssignee) => {
+ const confirmed = await confirm({
+ variant: "destructive",
+ title: localize("com_permission.confirm_revoke"),
+ description: assignee.subject.name || assignee.subject.id,
+ confirmText: localize("com_permission.action_revoke"),
+ });
+ if (!confirmed) return;
+ await mutateAssignee(assignee, { op: "REMOVE" });
+ };
+
const handleLoadMore = async () => {
if (!nextCursor || loadingMore) return;
setLoadingMore(true);
@@ -457,7 +458,7 @@ export function PermissionListTab({
target_model_key: modelKey,
})
}
- onRemove={setRemoveTarget}
+ onRemove={(item) => void handleRemove(item)}
/>
))}
{visibleAssignees.length === 0 && (
@@ -484,33 +485,6 @@ export function PermissionListTab({
)}
-
- {
- if (!nextOpen) setRemoveTarget(null);
- }}
- >
-
-
-
- {localize("com_permission.confirm_revoke")}
-
-
-
- {localize("cancel")}
- {
- if (removeTarget) {
- void mutateAssignee(removeTarget, { op: "REMOVE" });
- }
- }}
- >
- {localize("confirm")}
-
-
-
-
>
);
}
diff --git a/src/frontend/platform/src/components/bs-comp/permission/PermissionListTab.tsx b/src/frontend/platform/src/components/bs-comp/permission/PermissionListTab.tsx
index cdd1a11e0f..692b3a09f5 100644
--- a/src/frontend/platform/src/components/bs-comp/permission/PermissionListTab.tsx
+++ b/src/frontend/platform/src/components/bs-comp/permission/PermissionListTab.tsx
@@ -213,7 +213,7 @@ export function PermissionListTab({
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
- const [pendingAssigneeId, setPendingAssigneeId] = useState(null)
+ const [pendingAssigneeId, setPendingAssigneeId] = useState(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
diff --git a/src/frontend/platform/src/controllers/API/permission.ts b/src/frontend/platform/src/controllers/API/permission.ts
index b43d399ebe..9e79f50b13 100644
--- a/src/frontend/platform/src/controllers/API/permission.ts
+++ b/src/frontend/platform/src/controllers/API/permission.ts
@@ -145,7 +145,7 @@ export interface PermissionGrantSource {
}
export interface PermissionGrantAssignee {
- assignee_id: number
+ assignee_id: string
assignee_version: number
subject: PermissionGrantSubject
model: GrantablePermissionModel
@@ -185,13 +185,13 @@ export type PermissionGrantMutationChange =
}
| {
op: "MOVE"
- assignee_id: number
+ assignee_id: string
expected_assignee_version: number
target_model_key: string
}
| {
op: "REMOVE"
- assignee_id: number
+ assignee_id: string
expected_assignee_version: number
}
diff --git a/src/frontend/platform/src/test/f048PermissionApi.test.ts b/src/frontend/platform/src/test/f048PermissionApi.test.ts
index ad2eb2b283..bc38f38996 100644
--- a/src/frontend/platform/src/test/f048PermissionApi.test.ts
+++ b/src/frontend/platform/src/test/f048PermissionApi.test.ts
@@ -103,13 +103,13 @@ describe("F048 Platform permission API", () => {
},
{
op: "MOVE",
- assignee_id: 91,
+ assignee_id: "91",
expected_assignee_version: 2,
target_model_key: "editor",
},
{
op: "REMOVE",
- assignee_id: 92,
+ assignee_id: "92",
expected_assignee_version: 3,
},
],
diff --git a/src/frontend/platform/src/test/f048PermissionDialog.test.tsx b/src/frontend/platform/src/test/f048PermissionDialog.test.tsx
index 08cc364abe..102c61f6cd 100644
--- a/src/frontend/platform/src/test/f048PermissionDialog.test.tsx
+++ b/src/frontend/platform/src/test/f048PermissionDialog.test.tsx
@@ -31,7 +31,7 @@ const customContext = {
}
const protectedAssignee = {
- assignee_id: 81,
+ assignee_id: "81",
assignee_version: 4,
subject: { type: "user" as const, id: "3", name: "Creator" },
model: {
diff --git a/src/frontend/platform/src/test/f048PermissionGrantTab.test.tsx b/src/frontend/platform/src/test/f048PermissionGrantTab.test.tsx
index a5e83af36b..3338ec2a90 100644
--- a/src/frontend/platform/src/test/f048PermissionGrantTab.test.tsx
+++ b/src/frontend/platform/src/test/f048PermissionGrantTab.test.tsx
@@ -50,7 +50,7 @@ const context: ResourcePermissionContext = {
}
const editableAssignee: PermissionGrantAssignee = {
- assignee_id: 41,
+ assignee_id: "41",
assignee_version: 2,
subject: { type: "user", id: "10", name: "Bob" },
model: { key: "viewer", name: "Viewer", level: 1, active: true },
@@ -63,7 +63,7 @@ const editableAssignee: PermissionGrantAssignee = {
const protectedAssignee: PermissionGrantAssignee = {
...editableAssignee,
- assignee_id: 42,
+ assignee_id: "42",
assignee_version: 5,
subject: { type: "user", id: "11", name: "Creator" },
model: { key: "owner", name: "Owner", level: 4, active: true },
@@ -196,7 +196,7 @@ describe("F048 PermissionGrantTab", () => {
changes: [
{
op: "MOVE",
- assignee_id: 41,
+ assignee_id: "41",
expected_assignee_version: 2,
target_model_key: "editor",
},
@@ -233,7 +233,7 @@ describe("F048 PermissionGrantTab", () => {
changes: [
{
op: "REMOVE",
- assignee_id: 41,
+ assignee_id: "41",
expected_assignee_version: 2,
},
],
diff --git a/src/frontend/platform/src/test/f048PermissionRoster.test.tsx b/src/frontend/platform/src/test/f048PermissionRoster.test.tsx
index a47808aaa8..8265957adc 100644
--- a/src/frontend/platform/src/test/f048PermissionRoster.test.tsx
+++ b/src/frontend/platform/src/test/f048PermissionRoster.test.tsx
@@ -26,7 +26,7 @@ const customContext: ResourcePermissionContext = {
}
const directAssignee = {
- assignee_id: 101,
+ assignee_id: "101",
assignee_version: 3,
subject: { type: "user" as const, id: "7", name: "Alice" },
model: {
@@ -43,7 +43,7 @@ const directAssignee = {
}
const departmentAssignee = {
- assignee_id: 102,
+ assignee_id: "102",
assignee_version: 4,
subject: { type: "user" as const, id: "7", name: "Alice" },
model: {
From 8860ced1fcf2ef32465064115af97125667f09d9 Mon Sep 17 00:00:00 2001
From: dolphin
Date: Tue, 11 Aug 2026 12:26:08 +0800
Subject: [PATCH 02/38] fix(permission): scope Catalog publish reads and label
the state a failed one leaves
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
116 上发布 catalog 跑了 423 秒然后 500,日志里是 OpenFGA 的
`validation_error`。一次事故牵出四个问题,都在这条冷路径上。
**发布必然失败。** `read_active_release_keys` 只给了 user 和 relation,没给对象
类型。Read API 要求对象类型必填,且对象 id 与 user 不能同时为空,否则一律拒绝。
它在发布的最后一步 commit 才被调用,所以前面的活全白干,死在终点线上。
**读取量与库大小成正比,而不是与发布内容成正比。** staging 和校验各发一次
**不带过滤条件**的 Read,那是全库扫描,客户端每页 100 条 —— 77348 条 tuple 要
774 次往返,两次就是 1548 次强一致读。而发布真正要核对的只是自己那几百条
catalog tuple,涉及两种对象类型。改成按计划涉及的对象逐个读、并发 8 路。
116 实测:全量扫描 77348 条 / 774 次请求 / 206.8s;改后 active 指针 1 条 /
1 次 / 0.02s,模型发布标记 354 条 / 4 次 / 0.04s。语义未变,仍是同一批 tuple、
同样的强一致存在性比对。
**恢复路径依赖那个坏掉的函数。** commit 失败会转入 `_resolve_unknown_commit`,
而它第一行又调 `read_active_release_keys`,于是二次抛出、异常穿透整个 publish,
`fail_closed` 根本没机会执行。发布前给 CURRENT release 上的写锁就此留在库里。
封锁本身是设计意图(失败即封锁,只能前向修复),但**封锁却不打标记**不是:
release 停在 PROJECTING,没有 FAILED_CLOSED,没有事件,没有任何记录。运行中的
进程用着内存里已初始化的运行时照常服务,直到下一次重启才全线 500 —— 这次就是
隔了 4 天才发作。现在指针读不出来也会落进同一个带原因的终态。
**报错指错了方向。** 门禁被 latch 后一律报「Permission data migration is
required」,而真实原因是「CURRENT Permission Catalog is write fenced」,害人去
找一个根本不存在的迁移。改为把 latch 时的真实原因透出来。
顺带给客户端加了本地校验:不合法的 Read 过滤在发出前就报清楚缺什么,而不是等
服务端回一个含糊的 validation_error。过滤规则是在真实 OpenFGA 上逐组合验证的。
测试:新增 8 个用例(客户端过滤校验、投影器只读计划内对象、失败必留标记、门禁
报真实原因)。test_f048_catalog_runtime 里的假 OpenFGA 原本只认精确对象匹配,
按线上语义补上了类型前缀过滤。全量权限测试 626 通过 / 13 失败,与改动前基线的
13 个完全同批,无新增。
Co-Authored-By: Claude Opus 5 (1M context)
---
src/backend/bisheng/core/openfga/client.py | 12 ++
.../permission/application/catalog_api.py | 46 ++++--
.../permission/application/process_runtime.py | 16 +-
.../domain/services/catalog_service.py | 40 ++++-
.../test_f048_catalog_projector_reads.py | 100 ++++++++++++
.../permission/test_f048_catalog_runtime.py | 7 +-
.../test/permission/test_f048_fga_client.py | 42 +++++
.../test_f048_publish_failure_is_labelled.py | 153 ++++++++++++++++++
8 files changed, 398 insertions(+), 18 deletions(-)
create mode 100644 src/backend/test/permission/test_f048_catalog_projector_reads.py
create mode 100644 src/backend/test/permission/test_f048_publish_failure_is_labelled.py
diff --git a/src/backend/bisheng/core/openfga/client.py b/src/backend/bisheng/core/openfga/client.py
index ad3ce8138e..f52dd2c937 100644
--- a/src/backend/bisheng/core/openfga/client.py
+++ b/src/backend/bisheng/core/openfga/client.py
@@ -268,6 +268,12 @@ async def read_tuples(
) -> list[dict]:
"""Read tuples matching the given filter.
+ Pass no filter at all to walk the whole Store. Any other combination must
+ satisfy the Read API: the object type is mandatory, and the object id and
+ the user cannot both be empty. Callers used to learn this the hard way —
+ the server answers a filter it dislikes with a generic validation_error,
+ which surfaced as a 500 only after the surrounding work had already run.
+
Returns list of {"key": {"user": ..., "relation": ..., "object": ...}, "timestamp": ...}.
"""
tuple_key: dict[str, str] = {}
@@ -277,6 +283,12 @@ async def read_tuples(
tuple_key["relation"] = relation
if object:
tuple_key["object"] = object
+ if tuple_key:
+ object_type, separator, object_id = (object or "").partition(":")
+ if not object_type or not separator:
+ raise ValueError(f"OpenFGA read filter needs an object type, got object={object!r}")
+ if not object_id and not user:
+ raise ValueError("OpenFGA read filter needs an object id or a user, got neither")
tuples: list[dict] = []
continuation_token: str | None = None
while True:
diff --git a/src/backend/bisheng/permission/application/catalog_api.py b/src/backend/bisheng/permission/application/catalog_api.py
index f9907d9406..cb350ca280 100644
--- a/src/backend/bisheng/permission/application/catalog_api.py
+++ b/src/backend/bisheng/permission/application/catalog_api.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
from collections.abc import AsyncIterator, Callable
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from dataclasses import dataclass, replace
@@ -78,6 +79,7 @@
ZERO_CHECKSUM = "0" * 64
HIGHER_CONSISTENCY = "HIGHER_CONSISTENCY"
CATALOG_STAGE_BATCH_SIZE = 80
+CATALOG_READ_CONCURRENCY = 8
ACTIVE_OPERATION_STATUSES = (
"PREPARED",
"STAGING",
@@ -1087,10 +1089,7 @@ async def stage_model_releases(
) -> None:
expected = self._expected_tuples(draft)
await self._persist_plan(draft.release_id, expected)
- present = {
- (row["user"], row["relation"], row["object"])
- for row in await self._client.read_tuples(consistency=HIGHER_CONSISTENCY)
- }
+ present = await self._read_present(expected)
missing = [row for row in expected if (row["user"], row["relation"], row["object"]) not in present]
for index in range(0, len(missing), CATALOG_STAGE_BATCH_SIZE):
batch = missing[index : index + CATALOG_STAGE_BATCH_SIZE]
@@ -1102,17 +1101,43 @@ async def run_model_tests(
self,
draft: CatalogDraftSnapshot,
) -> None:
- expected = {(row["user"], row["relation"], row["object"]) for row in self._expected_tuples(draft)}
- present = {
- (row["user"], row["relation"], row["object"])
- for row in await self._client.read_tuples(consistency=HIGHER_CONSISTENCY)
- }
+ planned = self._expected_tuples(draft)
+ expected = {(row["user"], row["relation"], row["object"]) for row in planned}
+ present = await self._read_present(planned)
missing = expected - present
if missing:
raise PermissionProjectionFailedError(msg=f"Catalog staged tuple verification failed: {len(missing)}")
if draft.model_release is None:
raise PermissionPublishNotReadyError(msg="Catalog model release is missing")
+ async def _read_present(
+ self,
+ planned: list[dict[str, str]],
+ ) -> set[tuple[str, str, str]]:
+ """Read only the tuples this plan touches, one concrete object at a time.
+
+ An unfiltered Read walks the whole Store at 100 tuples per request, so
+ checking a few hundred Catalog tuples against a 77k-tuple Store cost
+ ~774 round trips — twice per publish, at HIGHER_CONSISTENCY. Scoping the
+ reads to the planned objects makes the cost proportional to the plan
+ instead of the Store, and the objects are independent so they overlap.
+ """
+
+ objects = sorted({row["object"] for row in planned})
+ if not objects:
+ return set()
+ semaphore = asyncio.Semaphore(CATALOG_READ_CONCURRENCY)
+
+ async def read(object_key: str) -> list[dict]:
+ async with semaphore:
+ return await self._client.read_tuples(
+ object=object_key,
+ consistency=HIGHER_CONSISTENCY,
+ )
+
+ pages = await asyncio.gather(*(read(key) for key in objects))
+ return {(row["user"], row["relation"], row["object"]) for page in pages for row in page}
+
async def arm_recent_marker(
self,
draft: CatalogDraftSnapshot,
@@ -1149,9 +1174,12 @@ async def commit_active(
return _commit_checksum(changes)
async def read_active_release_keys(self) -> frozenset[str]:
+ # OpenFGA rejects a tuple_key without an object type, so the filter has
+ # to name the type even though the prefix check below already does.
rows = await self._client.read_tuples(
user="user:*",
relation="active",
+ object="permission_catalog_release:",
consistency=HIGHER_CONSISTENCY,
)
prefix = "permission_catalog_release:"
diff --git a/src/backend/bisheng/permission/application/process_runtime.py b/src/backend/bisheng/permission/application/process_runtime.py
index 08fa1a0637..2f640fe04d 100644
--- a/src/backend/bisheng/permission/application/process_runtime.py
+++ b/src/backend/bisheng/permission/application/process_runtime.py
@@ -38,7 +38,7 @@ async def bind_catalog_runtime(
async def heartbeat(self) -> bool: ...
- async def mark_migration_required(self) -> None: ...
+ async def mark_migration_required(self, *, reason: str = ...) -> None: ...
def readiness(self) -> dict: ...
@@ -78,15 +78,21 @@ def register_f048_permission_runtime_context(
async def initialize() -> ProcessPermissionRuntime:
manager = app_context.get_context("openfga")
client = await manager.async_get_instance()
- if manager.readiness().get("migration_required"):
- raise PermissionPublishNotReadyError(msg="Permission data migration is required")
+ readiness = manager.readiness()
+ if readiness.get("migration_required"):
+ # Report what actually latched the gate. A fenced Catalog left by a
+ # crashed publish reported "migration is required" here, which sent
+ # the operator looking for a migration that was never pending.
+ raise PermissionPublishNotReadyError(
+ msg=str(readiness.get("error") or "Permission data migration is required")
+ )
try:
runtime = await initializer(client)
except (
AuthorizationModelMismatchError,
PermissionPublishNotReadyError,
- ):
- await manager.mark_migration_required()
+ ) as exc:
+ await manager.mark_migration_required(reason=str(exc) or "permission_data_migration_required")
raise
await bind_f048_process_runtime(
manager,
diff --git a/src/backend/bisheng/permission/domain/services/catalog_service.py b/src/backend/bisheng/permission/domain/services/catalog_service.py
index 0da63e1bc1..6cee2fcfb0 100644
--- a/src/backend/bisheng/permission/domain/services/catalog_service.py
+++ b/src/backend/bisheng/permission/domain/services/catalog_service.py
@@ -437,7 +437,21 @@ async def _resolve_unknown_commit(
original_error: Exception,
allow_retry: bool,
) -> CatalogPublishOutcome:
- active = await self._projector.read_active_release_keys()
+ try:
+ active = await self._projector.read_active_release_keys()
+ except Exception as exc:
+ # Reading the pointer is how this path tells "committed" from "never
+ # committed". If that read itself fails the publication is
+ # unresolvable, so land it in the fenced terminal state instead of
+ # letting the error escape — an escaping error leaves the CURRENT
+ # release fenced with no FAILED_CLOSED marker and no event, which is
+ # invisible until a restart takes the whole permission runtime down.
+ return await self._fail_closed(
+ context,
+ reason=(
+ f"Catalog active pointer is unreadable after commit: error={exc}, original_error={original_error}"
+ ),
+ )
old_key = context.current_release_key
new_key = context.draft.release_key
@@ -467,9 +481,29 @@ async def _resolve_unknown_commit(
reconciled=True,
)
- reason = (
- f"Catalog active pointer invariant violated after commit: active={sorted(active)}, error={original_error}"
+ await self._fail_closed(
+ context,
+ reason=(
+ f"Catalog active pointer invariant violated after commit: "
+ f"active={sorted(active)}, error={original_error}"
+ ),
)
+
+ async def _fail_closed(
+ self,
+ context: CatalogPublishContext,
+ *,
+ reason: str,
+ ) -> CatalogPublishOutcome:
+ """Record the fenced terminal state, then raise.
+
+ Publishing fences the CURRENT release and only a resolved commit lifts
+ that fence, so an unresolvable publication is meant to stay fenced. What
+ it must never do is stay fenced *unlabelled*: the release has to end up
+ FAILED_CLOSED with a reason, or the next process restart refuses to serve
+ permissions with nothing on record explaining why.
+ """
+
await self._state.fail_closed(context, reason=reason)
await self._emit(
context,
diff --git a/src/backend/test/permission/test_f048_catalog_projector_reads.py b/src/backend/test/permission/test_f048_catalog_projector_reads.py
new file mode 100644
index 0000000000..daf58fed31
--- /dev/null
+++ b/src/backend/test/permission/test_f048_catalog_projector_reads.py
@@ -0,0 +1,100 @@
+"""Catalog publish reads OpenFGA in proportion to the plan, not the Store.
+
+Both staging and verification used to issue an unfiltered Read, which walks the
+whole Store 100 tuples per request. On a 77k-tuple Store that is ~774 round
+trips each, at HIGHER_CONSISTENCY — one observed publish spent 423s and then
+died at its commit step, where a filter without an object type earned a generic
+OpenFGA validation_error.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from bisheng.permission.application.catalog_api import OpenFGACatalogProjector
+
+
+class _RecordingClient:
+ """Answer Reads from a tiny Store and record exactly how they were filtered."""
+
+ def __init__(self, tuples: set[tuple[str, str, str]]) -> None:
+ self.store_id = "store-1"
+ self.model_id = "model-f048"
+ self.tuples = tuples
+ self.read_filters: list[dict[str, str | None]] = []
+
+ async def read_tuples(
+ self,
+ user: str | None = None,
+ relation: str | None = None,
+ object: str | None = None,
+ consistency: str | None = None,
+ ) -> list[dict]:
+ del consistency
+ self.read_filters.append({"user": user, "relation": relation, "object": object})
+ if object is not None and not object.partition(":")[0]:
+ raise AssertionError(f"read filter without an object type: {object!r}")
+ if object is not None and object.endswith(":") and not user:
+ raise AssertionError("type-only object filter needs a user")
+ matched = [
+ {"user": row_user, "relation": row_relation, "object": row_object}
+ for row_user, row_relation, row_object in sorted(self.tuples)
+ if (user is None or row_user == user)
+ and (relation is None or row_relation == relation)
+ and (object is None or row_object == object or (object.endswith(":") and row_object.startswith(object)))
+ ]
+ return matched
+
+
+PLANNED = [
+ {"user": "permission_model_release:r1", "relation": "release", "object": "permission_model:viewer"},
+ {"user": "permission_catalog_release:c1", "relation": "catalog", "object": "permission_model_release:r1"},
+ {"user": "user:*", "relation": "enabled_marker", "object": "permission_model_release:r1"},
+]
+
+# Everything a real Store also holds and a publish has no business reading.
+UNRELATED = {("user:9", "ordinary_assignee", f"permission_grant:g{index}") for index in range(500)}
+
+
+def _projector(client: _RecordingClient) -> OpenFGACatalogProjector:
+ return OpenFGACatalogProjector(client=client, marker=None)
+
+
+@pytest.mark.asyncio
+async def test_present_reads_only_the_planned_objects() -> None:
+ present_rows = {(row["user"], row["relation"], row["object"]) for row in PLANNED[:2]}
+ client = _RecordingClient(present_rows | UNRELATED)
+
+ present = await _projector(client)._read_present(PLANNED)
+
+ assert present == present_rows
+ # One Read per distinct planned object — never an unfiltered Store walk.
+ assert len(client.read_filters) == 2
+ assert {entry["object"] for entry in client.read_filters} == {
+ "permission_model:viewer",
+ "permission_model_release:r1",
+ }
+ assert all(entry["object"] for entry in client.read_filters)
+
+
+@pytest.mark.asyncio
+async def test_present_is_empty_without_a_plan() -> None:
+ client = _RecordingClient(UNRELATED)
+
+ assert await _projector(client)._read_present([]) == set()
+ assert client.read_filters == []
+
+
+@pytest.mark.asyncio
+async def test_active_release_keys_filter_names_the_object_type() -> None:
+ client = _RecordingClient({("user:*", "active", "permission_catalog_release:c1")} | UNRELATED)
+
+ assert await _projector(client).read_active_release_keys() == frozenset({"c1"})
+
+ assert client.read_filters == [
+ {
+ "user": "user:*",
+ "relation": "active",
+ "object": "permission_catalog_release:",
+ }
+ ]
diff --git a/src/backend/test/permission/test_f048_catalog_runtime.py b/src/backend/test/permission/test_f048_catalog_runtime.py
index db8d3b6ec9..84221b335d 100644
--- a/src/backend/test/permission/test_f048_catalog_runtime.py
+++ b/src/backend/test/permission/test_f048_catalog_runtime.py
@@ -124,12 +124,17 @@ async def read_tuples(
consistency: str | None = None,
) -> list[dict]:
del consistency
+ # An object ending in ":" is a type filter, matching every id of that
+ # type — the same shape the real Read API accepts (and it requires a
+ # user alongside it, which this fake asserts rather than silently allows).
+ if object is not None and object.endswith(":") and not user:
+ raise AssertionError("type-only object filter needs a user")
return [
{"user": item_user, "relation": item_relation, "object": item_object}
for item_user, item_relation, item_object in sorted(self.tuples)
if (user is None or item_user == user)
and (relation is None or item_relation == relation)
- and (object is None or item_object == object)
+ and (object is None or item_object == object or (object.endswith(":") and item_object.startswith(object)))
]
@staticmethod
diff --git a/src/backend/test/permission/test_f048_fga_client.py b/src/backend/test/permission/test_f048_fga_client.py
index 065fa282bd..b57570b98e 100644
--- a/src/backend/test/permission/test_f048_fga_client.py
+++ b/src/backend/test/permission/test_f048_fga_client.py
@@ -270,3 +270,45 @@ def test_for_model_reuses_store_without_a_legacy_client(client: FGAClient) -> No
assert migration_client.store_id == client.store_id
assert migration_client.model_id == "model-target"
assert migration_client is not client
+
+
+@pytest.mark.asyncio
+async def test_read_filter_requires_an_object_type(
+ client: FGAClient,
+) -> None:
+ """A filtered Read without an object type is rejected before it is sent.
+
+ OpenFGA answers such a filter with a generic validation_error. The Catalog
+ publish path issued one at its final commit step, so the failure only showed
+ up as a 500 after the whole publish had already run.
+ """
+
+ client._post = AsyncMock()
+
+ with pytest.raises(ValueError, match="object type"):
+ await client.read_tuples(user="user:*", relation="active")
+
+ with pytest.raises(ValueError, match="object type"):
+ await client.read_tuples(object="permission_catalog_release")
+
+ client._post.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_read_filter_requires_an_object_id_or_a_user(
+ client: FGAClient,
+) -> None:
+ """A type-only object filter is legal only when a user narrows it."""
+
+ client._post = AsyncMock()
+ with pytest.raises(ValueError, match="object id or a user"):
+ await client.read_tuples(object="permission_model_release:")
+ client._post.assert_not_awaited()
+
+ client._post = AsyncMock(return_value={"tuples": []})
+ assert await client.read_tuples(object="permission_model_release:", user="user:*") == []
+ _, body = client._post.call_args.args
+ assert body["tuple_key"] == {
+ "user": "user:*",
+ "object": "permission_model_release:",
+ }
diff --git a/src/backend/test/permission/test_f048_publish_failure_is_labelled.py b/src/backend/test/permission/test_f048_publish_failure_is_labelled.py
new file mode 100644
index 0000000000..d2bb30032e
--- /dev/null
+++ b/src/backend/test/permission/test_f048_publish_failure_is_labelled.py
@@ -0,0 +1,153 @@
+"""An unresolvable publish must leave a labelled state, not a silent fence.
+
+Publishing fences the CURRENT Catalog and only a resolved commit lifts that
+fence, so staying fenced after an unresolvable commit is deliberate. Staying
+fenced *unlabelled* is not: a crashed publish left the release fenced with no
+FAILED_CLOSED marker and no event, which stayed invisible until the next restart
+refused to initialize the permission runtime — reporting a data migration that
+was never pending.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from bisheng.common.errcode.permission import (
+ PermissionProjectionFailedError,
+ PermissionPublishNotReadyError,
+)
+from bisheng.core.openfga.exceptions import FGAClientError
+from bisheng.permission.application.process_runtime import (
+ register_f048_permission_runtime_context,
+)
+
+
+class _StubProjector:
+ """Fail the active-pointer read the way a rejected Read filter did."""
+
+ def __init__(self, error: Exception) -> None:
+ self.error = error
+ self.read_calls = 0
+
+ async def read_active_release_keys(self):
+ self.read_calls += 1
+ raise self.error
+
+
+class _RecordingState:
+ def __init__(self) -> None:
+ self.fail_closed_calls: list[str] = []
+
+ async def fail_closed(self, context, *, reason: str) -> None:
+ del context
+ self.fail_closed_calls.append(reason)
+
+
+def _service(projector: _StubProjector, state: _RecordingState):
+ from bisheng.permission.domain.services.catalog_service import CatalogService
+
+ service = CatalogService.__new__(CatalogService)
+ service._projector = projector
+ service._state = state
+
+ async def _emit(context, *, status, error=None):
+ del context, status, error
+
+ service._emit = _emit
+ return service
+
+
+@pytest.mark.asyncio
+async def test_unreadable_active_pointer_still_fails_closed() -> None:
+ projector = _StubProjector(
+ FGAClientError('OpenFGA 400: {"code":"validation_error","message":"object type is required"}')
+ )
+ state = _RecordingState()
+ service = _service(projector, state)
+
+ with pytest.raises(PermissionProjectionFailedError):
+ await service._resolve_unknown_commit(
+ object(),
+ original_error=RuntimeError("commit blew up"),
+ allow_retry=True,
+ )
+
+ assert projector.read_calls == 1
+ # The release is recorded as FAILED_CLOSED, carrying both errors, instead of
+ # the read error escaping and leaving the fence unexplained.
+ assert len(state.fail_closed_calls) == 1
+ reason = state.fail_closed_calls[0]
+ assert "unreadable" in reason
+ assert "validation_error" in reason
+ assert "commit blew up" in reason
+
+
+class _StubManager:
+ def __init__(self, readiness: dict) -> None:
+ self._readiness = readiness
+ self.marked: list[str] = []
+
+ def readiness(self) -> dict:
+ return self._readiness
+
+ async def mark_migration_required(self, *, reason: str = "permission_data_migration_required") -> None:
+ self.marked.append(reason)
+
+ async def async_get_instance(self):
+ return object()
+
+
+def _capture_initializer(monkeypatch, manager: _StubManager):
+ """Register the runtime context against a stub manager and return its init."""
+
+ from bisheng.core.context.manager import app_context
+
+ def get_context(name: str):
+ if name == "openfga":
+ return manager
+ raise KeyError(name)
+
+ monkeypatch.setattr(app_context, "get_context", get_context)
+
+ captured: dict = {}
+
+ def register_context(context, **kwargs):
+ del kwargs
+ captured["init"] = context.init_func
+
+ monkeypatch.setattr(app_context, "register_context", register_context)
+
+ async def initializer(client):
+ raise AssertionError("the initializer must not run while the gate is latched")
+
+ register_f048_permission_runtime_context(initializer)
+ return captured["init"]
+
+
+@pytest.mark.asyncio
+async def test_latched_gate_reports_the_reason_that_latched_it(monkeypatch) -> None:
+ manager = _StubManager(
+ {
+ "migration_required": True,
+ "error": "CURRENT Permission Catalog is write fenced",
+ }
+ )
+ initialize = _capture_initializer(monkeypatch, manager)
+
+ with pytest.raises(PermissionPublishNotReadyError) as excinfo:
+ await initialize()
+
+ # Not the generic "migration is required", which sent an operator hunting a
+ # migration that was never pending.
+ assert "write fenced" in str(excinfo.value)
+
+
+@pytest.mark.asyncio
+async def test_gate_falls_back_when_no_reason_was_recorded(monkeypatch) -> None:
+ manager = _StubManager({"migration_required": True, "error": None})
+ initialize = _capture_initializer(monkeypatch, manager)
+
+ with pytest.raises(PermissionPublishNotReadyError) as excinfo:
+ await initialize()
+
+ assert "migration is required" in str(excinfo.value)
From c0a6b7e364a905c28d587d09146f388298600420 Mon Sep 17 00:00:00 2001
From: dolphin
Date: Tue, 11 Aug 2026 12:28:32 +0800
Subject: [PATCH 03/38] style(permission): render the publish impact expiry as
local time
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
发布影响确认弹窗直接把后端的 ISO 串塞进文案,显示成
`2026-08-11T03:27:14.567728+00:00`。不只是不好看:那是 UTC,对 CST 用户比本地
时间早 8 小时,而这个影响窗口后端只给 10 分钟,照它判断还剩多久会直接误判成
早已过期。改为按本地时间渲染成 `2026-08-11 11:27:14`,窗口只有 10 分钟所以保留
到秒;无法解析的值原样返回。
Co-Authored-By: Claude Opus 5 (1M context)
---
.../components/permission/ImpactDialog.tsx | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/src/frontend/platform/src/pages/SystemPage/components/permission/ImpactDialog.tsx b/src/frontend/platform/src/pages/SystemPage/components/permission/ImpactDialog.tsx
index 61f4281b96..b753caf98b 100644
--- a/src/frontend/platform/src/pages/SystemPage/components/permission/ImpactDialog.tsx
+++ b/src/frontend/platform/src/pages/SystemPage/components/permission/ImpactDialog.tsx
@@ -11,6 +11,7 @@ import type {
PermissionCatalogDraft,
PublishPermissionCatalogDraftRequest,
} from "@/controllers/API/permission"
+import { formatDate } from "@/util/utils"
import { AlertTriangle } from "lucide-react"
import { useState } from "react"
import { useTranslation } from "react-i18next"
@@ -46,6 +47,15 @@ function ImpactMetric({ label, value, testId }: ImpactMetricProps) {
)
}
+// The backend sends UTC ISO-8601 with microseconds; rendering it raw showed
+// both an unreadable string and a time 8 hours off for CST operators. The
+// impact window is only 10 minutes, so keep seconds.
+function formatExpiry(iso: string): string {
+ const parsed = new Date(iso)
+ if (Number.isNaN(parsed.getTime())) return iso
+ return formatDate(parsed, "yyyy-MM-dd HH:mm:ss")
+}
+
function createPublishIdempotencyKey(): string {
return `catalog-publish-${Date.now()}-${Math.random()
.toString(36)
@@ -155,7 +165,7 @@ export function ImpactDialog({
)}
- {t("impact.expiresAt", { value: draft.impact.expires_at })}
+ {t("impact.expiresAt", { value: formatExpiry(draft.impact.expires_at) })}
From be1e914064a30abd356d2bcab93d6a2065d8b65f Mon Sep 17 00:00:00 2001
From: dolphin
Date: Tue, 11 Aug 2026 15:02:16 +0800
Subject: [PATCH 04/38] fix(permission): let one Catalog draft carry the whole
edit batch
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
「拖拽完就保存了」是合理预期,但动作等级看板不是这么工作的:每拖一次就以
**当前生效版本**为基准新建一份草案,而一份草案只装一个改动。连改三处会得到三份
互不相干的草案,点发布只发布最后一份,**前两处即使走完完整发布流程也会静默丢失**。
库里因此堆了 45 份废弃草案,且前后端都没有删除接口。
而且这个 tab 上根本没有发布按钮 —— 只有一个描边小按钮「查看影响」,真正的
「确认发布」藏在它弹出的对话框里;页面上唯一的提示写的是「预计影响 N 个资源」,
只讲影响量,从头到尾没有一个字说尚未发布(整个权限模块搜不到「未发布」字样)。
所以用户拖完就走,改动全丢。
后端:`CatalogDraftRequest.change` 改为 `changes` 元组,`_apply_change` 改为
`_apply_changes`,把整批改动折叠到基准版本上再做一次校验。校验放在折叠之后而不是
每步之后——一批编辑可能途经某个它根本不会发布的中间状态,只有真正要发布的那个
状态需要成立。
前端:编辑改为即时落本地、不发请求;待发布改动由「本地状态与已发布版本的差异」
推导,所以把卡片拖回原处会自动撤销该改动,而不是再排一条反向的。头部换成
「发布更改」主按钮 + 「放弃更改」,提示条改为「有 N 项改动尚未发布」,草案生成
失败会明确报错(原先是 `void` 掉的未处理拒绝,静默无声)。
测试:后端新增 2 个(整批折叠、同一动作后写覆盖先写);前端看板用例按新行为重写
(编辑不触网、发布时一次提交全部、移回原处撤销改动、放弃更改、草案失败提示)。
后端 628 通过 / 13 失败与基线同批;前端 22 个相关用例全过,tsc-strict 与 eslint
干净。`pnpm check-i18n` 仍失败,但基线一模一样(250xx 权限错误码缺前端文案,与本
次无关)。
Co-Authored-By: Claude Opus 5 (1M context)
---
.../permission/application/catalog_api.py | 178 ++++++++++--------
.../bisheng/permission/domain/schemas/f048.py | 5 +-
.../test/permission/test_f048_catalog_api.py | 26 +--
.../permission/test_f048_catalog_runtime.py | 136 +++++++++++--
.../public/locales/en-US/permission.json | 7 +-
.../public/locales/ja/permission.json | 7 +-
.../public/locales/zh-Hans/permission.json | 7 +-
.../src/controllers/API/permission.ts | 2 +-
.../components/RolesAndPermissions.tsx | 6 +-
.../permission/ActionLevelBoard.tsx | 153 ++++++++-------
.../components/permission/ModelEditor.tsx | 14 +-
.../src/test/f048ActionLevelBoard.test.tsx | 117 +++++++++---
.../src/test/f048ModelEditor.test.tsx | 56 +++---
.../src/test/f048PermissionApi.test.ts | 12 +-
.../src/test/f048RolesAndPermissions.test.tsx | 28 +--
15 files changed, 497 insertions(+), 257 deletions(-)
diff --git a/src/backend/bisheng/permission/application/catalog_api.py b/src/backend/bisheng/permission/application/catalog_api.py
index cb350ca280..97089784b6 100644
--- a/src/backend/bisheng/permission/application/catalog_api.py
+++ b/src/backend/bisheng/permission/application/catalog_api.py
@@ -1348,8 +1348,8 @@ async def create_draft(
before = await self._state.load_snapshot(reservation.predecessor_id)
if before.action_release is None or before.model_release is None:
raise PermissionPublishNotReadyError()
- actions, customs, standard_policy = await self._apply_change(
- request.change,
+ actions, customs, standard_policy = await self._apply_changes(
+ request.changes,
before,
)
try:
@@ -1482,15 +1482,22 @@ async def _release_payload(
"published_at": (_as_utc(row.published_at).isoformat() if row.published_at is not None else None),
}
- async def _apply_change(
+ async def _apply_changes(
self,
- change: CatalogChangeRequest,
+ changes: tuple[CatalogChangeRequest, ...],
before: CatalogDraftSnapshot,
) -> tuple[
tuple[CatalogAction, ...],
tuple[CustomModelSelection, ...],
dict[str, bool],
]:
+ """Fold the whole edit batch onto the base release, then validate once.
+
+ Validation runs on the resulting state rather than after each change: a
+ batch may pass through an intermediate arrangement it never publishes,
+ and only the state that actually ships has to hold.
+ """
+
assert before.action_release is not None
assert before.model_release is not None
actions = list(before.action_release.actions)
@@ -1508,90 +1515,95 @@ async def _apply_change(
}
standard_by_key = {model.model_key: model for model in before.model_release.models if model.kind == "STANDARD"}
standard_policy = {key: model.allow_same_level for key, model in standard_by_key.items()}
- kind = change.type
- if kind in {
- CatalogChangeType.ASSIGN_ACTION_LEVEL,
- CatalogChangeType.SET_ACTION_ACTIVE,
- }:
- index = next(
- (index for index, action in enumerate(actions) if action.code == change.action_code),
- None,
- )
- if index is None:
- raise InvalidCatalogActionError()
- if kind == CatalogChangeType.ASSIGN_ACTION_LEVEL:
- actions[index] = replace(
- actions[index],
- level=(int(change.level) if change.level is not None else None),
- )
- elif change.active is None:
- raise InvalidCatalogActionError()
- else:
- actions[index] = replace(
- actions[index],
- active=change.active,
+ touched_standard_keys: set[str] = set()
+ for change in changes:
+ kind = change.type
+ if kind in {
+ CatalogChangeType.ASSIGN_ACTION_LEVEL,
+ CatalogChangeType.SET_ACTION_ACTIVE,
+ }:
+ index = next(
+ (index for index, action in enumerate(actions) if action.code == change.action_code),
+ None,
)
- elif kind == CatalogChangeType.CREATE_MODEL:
- key = change.model_key or uuid4().hex
- if key in custom_by_key or key in standard_by_key or not change.name or not change.action_codes:
- raise PermissionModelStateConflictError()
- custom_by_key[key] = CustomModelSelection(
- model_key=key,
- name=change.name,
- action_codes=change.action_codes,
- active=change.active is not False,
- allow_same_level=bool(change.allow_same_level),
- )
- elif kind == CatalogChangeType.UPDATE_MODEL:
- model = self._custom_model(change.model_key, custom_by_key)
- custom_by_key[model.model_key] = replace(
- model,
- name=change.name if change.name is not None else model.name,
- action_codes=(change.action_codes if change.action_codes is not None else model.action_codes),
- active=(change.active if change.active is not None else model.active),
- allow_same_level=(
- change.allow_same_level if change.allow_same_level is not None else model.allow_same_level
- ),
- )
- elif kind == CatalogChangeType.SET_MODEL_ACTIVE:
- if change.active is None:
- raise PermissionModelStateConflictError()
- model = self._custom_model(change.model_key, custom_by_key)
- custom_by_key[model.model_key] = replace(
- model,
- active=change.active,
- )
- elif kind == CatalogChangeType.DELETE_MODEL:
- model = self._custom_model(change.model_key, custom_by_key)
- derived = next(item for item in before.model_release.models if item.model_key == model.model_key)
- references = await self._state.grant_references()
- try:
- ensure_model_deletable(
- derived,
- reference_count=len(references.get(model.model_key, ())),
+ if index is None:
+ raise InvalidCatalogActionError()
+ if kind == CatalogChangeType.ASSIGN_ACTION_LEVEL:
+ actions[index] = replace(
+ actions[index],
+ level=(int(change.level) if change.level is not None else None),
+ )
+ elif change.active is None:
+ raise InvalidCatalogActionError()
+ else:
+ actions[index] = replace(
+ actions[index],
+ active=change.active,
+ )
+ elif kind == CatalogChangeType.CREATE_MODEL:
+ key = change.model_key or uuid4().hex
+ if key in custom_by_key or key in standard_by_key or not change.name or not change.action_codes:
+ raise PermissionModelStateConflictError()
+ custom_by_key[key] = CustomModelSelection(
+ model_key=key,
+ name=change.name,
+ action_codes=change.action_codes,
+ active=change.active is not False,
+ allow_same_level=bool(change.allow_same_level),
)
- except ValueError as exc:
- raise PermissionModelStateConflictError(
- exception=exc,
- msg=str(exc),
- ) from exc
- del custom_by_key[model.model_key]
- elif kind == CatalogChangeType.SET_ALLOW_SAME_LEVEL:
- if change.allow_same_level is None or not change.model_key:
- raise PermissionModelStateConflictError()
- if change.model_key in standard_by_key:
- standard_policy[change.model_key] = change.allow_same_level
- else:
- model = self._custom_model(
- change.model_key,
- custom_by_key,
+ elif kind == CatalogChangeType.UPDATE_MODEL:
+ model = self._custom_model(change.model_key, custom_by_key)
+ custom_by_key[model.model_key] = replace(
+ model,
+ name=change.name if change.name is not None else model.name,
+ action_codes=(change.action_codes if change.action_codes is not None else model.action_codes),
+ active=(change.active if change.active is not None else model.active),
+ allow_same_level=(
+ change.allow_same_level if change.allow_same_level is not None else model.allow_same_level
+ ),
)
+ elif kind == CatalogChangeType.SET_MODEL_ACTIVE:
+ if change.active is None:
+ raise PermissionModelStateConflictError()
+ model = self._custom_model(change.model_key, custom_by_key)
custom_by_key[model.model_key] = replace(
model,
- allow_same_level=change.allow_same_level,
+ active=change.active,
)
- else:
- raise InvalidCatalogActionError()
+ elif kind == CatalogChangeType.DELETE_MODEL:
+ model = self._custom_model(change.model_key, custom_by_key)
+ derived = next(item for item in before.model_release.models if item.model_key == model.model_key)
+ references = await self._state.grant_references()
+ try:
+ ensure_model_deletable(
+ derived,
+ reference_count=len(references.get(model.model_key, ())),
+ )
+ except ValueError as exc:
+ raise PermissionModelStateConflictError(
+ exception=exc,
+ msg=str(exc),
+ ) from exc
+ del custom_by_key[model.model_key]
+ elif kind == CatalogChangeType.SET_ALLOW_SAME_LEVEL:
+ if change.allow_same_level is None or not change.model_key:
+ raise PermissionModelStateConflictError()
+ if change.model_key in standard_by_key:
+ standard_policy[change.model_key] = change.allow_same_level
+ else:
+ model = self._custom_model(
+ change.model_key,
+ custom_by_key,
+ )
+ custom_by_key[model.model_key] = replace(
+ model,
+ allow_same_level=change.allow_same_level,
+ )
+ else:
+ raise InvalidCatalogActionError()
+ if change.model_key and change.model_key in standard_by_key:
+ if change.type is not CatalogChangeType.SET_ALLOW_SAME_LEVEL:
+ touched_standard_keys.add(change.model_key)
try:
action_release = derive_action_release(actions)
derive_permission_models(
@@ -1600,7 +1612,7 @@ async def _apply_change(
standard_allow_same_level=standard_policy,
)
except ValueError as exc:
- if change.model_key in standard_by_key and kind not in {CatalogChangeType.SET_ALLOW_SAME_LEVEL}:
+ if touched_standard_keys:
raise ImmutableStandardModelError(
exception=exc,
msg=str(exc),
diff --git a/src/backend/bisheng/permission/domain/schemas/f048.py b/src/backend/bisheng/permission/domain/schemas/f048.py
index 53b97736eb..749d2e729d 100644
--- a/src/backend/bisheng/permission/domain/schemas/f048.py
+++ b/src/backend/bisheng/permission/domain/schemas/f048.py
@@ -186,7 +186,10 @@ class CatalogChangeRequest(StrictRequestModel):
class CatalogDraftRequest(StrictRequestModel):
idempotency_key: str = Field(min_length=1, max_length=64)
base_release_id: int = Field(gt=0)
- change: CatalogChangeRequest
+ # A draft carries the whole edit batch. One change per draft forced the UI to
+ # open a fresh draft off the CURRENT release for every edit, so publishing
+ # applied only the last one and silently dropped the rest.
+ changes: tuple[CatalogChangeRequest, ...] = Field(min_length=1, max_length=50)
class CatalogImpactDTO(BaseModel):
diff --git a/src/backend/test/permission/test_f048_catalog_api.py b/src/backend/test/permission/test_f048_catalog_api.py
index b321f54cf1..abcf12d99a 100644
--- a/src/backend/test/permission/test_f048_catalog_api.py
+++ b/src/backend/test/permission/test_f048_catalog_api.py
@@ -99,11 +99,13 @@ def test_catalog_get_and_draft_round_trip_use_unified_response() -> None:
json={
"idempotency_key": "draft-1",
"base_release_id": 12,
- "change": {
- "type": "ASSIGN_ACTION_LEVEL",
- "action_code": "edit",
- "level": 2,
- },
+ "changes": [
+ {
+ "type": "ASSIGN_ACTION_LEVEL",
+ "action_code": "edit",
+ "level": 2,
+ }
+ ],
},
).json()
fetched = client.get("/api/v1/permissions/catalog/drafts/13").json()
@@ -143,12 +145,14 @@ def test_catalog_semantic_errors_are_translated_to_unified_codes() -> None:
json={
"idempotency_key": "draft-invalid",
"base_release_id": 12,
- "change": {
- "type": "UPDATE_MODEL",
- "model_key": "owner",
- "name": "forbidden",
- "action_codes": ["unknown-action"],
- },
+ "changes": [
+ {
+ "type": "UPDATE_MODEL",
+ "model_key": "owner",
+ "name": "forbidden",
+ "action_codes": ["unknown-action"],
+ }
+ ],
},
).json()
assert body["status_code"] == expected
diff --git a/src/backend/test/permission/test_f048_catalog_runtime.py b/src/backend/test/permission/test_f048_catalog_runtime.py
index 84221b335d..a55434134b 100644
--- a/src/backend/test/permission/test_f048_catalog_runtime.py
+++ b/src/backend/test/permission/test_f048_catalog_runtime.py
@@ -345,10 +345,12 @@ async def test_action_level_draft_rebuilds_every_standard_and_custom_model(
request=CatalogDraftRequest(
idempotency_key="raise-edit",
base_release_id=int(current.id),
- change=CatalogChangeRequest(
- type=CatalogChangeType.ASSIGN_ACTION_LEVEL,
- action_code="edit",
- level=3,
+ changes=(
+ CatalogChangeRequest(
+ type=CatalogChangeType.ASSIGN_ACTION_LEVEL,
+ action_code="edit",
+ level=3,
+ ),
),
),
operator_id=7,
@@ -451,10 +453,12 @@ async def test_catalog_publish_allows_visibility_only_grant_after_action_level_c
request=CatalogDraftRequest(
idempotency_key="raise-download",
base_release_id=int(current.id),
- change=CatalogChangeRequest(
- type=CatalogChangeType.ASSIGN_ACTION_LEVEL,
- action_code="download",
- level=2,
+ changes=(
+ CatalogChangeRequest(
+ type=CatalogChangeType.ASSIGN_ACTION_LEVEL,
+ action_code="download",
+ level=2,
+ ),
),
),
operator_id=7,
@@ -491,10 +495,12 @@ async def test_catalog_publish_stages_complete_release_and_switches_once(
request=CatalogDraftRequest(
idempotency_key="disable-share",
base_release_id=int(current.id),
- change=CatalogChangeRequest(
- type=CatalogChangeType.SET_ACTION_ACTIVE,
- action_code="share",
- active=False,
+ changes=(
+ CatalogChangeRequest(
+ type=CatalogChangeType.SET_ACTION_ACTIVE,
+ action_code="share",
+ active=False,
+ ),
),
),
operator_id=7,
@@ -567,10 +573,12 @@ async def test_catalog_create_is_idempotent_and_current_shape_is_complete(
request = CatalogDraftRequest(
idempotency_key="same-draft",
base_release_id=int(current.id),
- change=CatalogChangeRequest(
- type=CatalogChangeType.SET_ALLOW_SAME_LEVEL,
- model_key="manager",
- allow_same_level=True,
+ changes=(
+ CatalogChangeRequest(
+ type=CatalogChangeType.SET_ALLOW_SAME_LEVEL,
+ model_key="manager",
+ allow_same_level=True,
+ ),
),
)
@@ -829,3 +837,99 @@ async def test_inherited_roster_uses_nearest_custom_ancestor(
(30, "LOCAL", None),
]
assert has_more is False
+
+
+async def test_draft_folds_every_change_in_the_batch(
+ session_factory: SessionFactory,
+) -> None:
+ """A batch must publish all of its edits, not just the last one.
+
+ The board used to open a fresh draft off the CURRENT release per edit, so a
+ session of three tweaks produced three one-change drafts and publishing any
+ of them silently dropped the other two.
+ """
+
+ fga = InMemoryCatalogFGA()
+ marker = FakeCatalogMarker()
+ current = await _seed_current(session_factory, fga)
+ api = _api(session_factory, fga, marker)
+
+ draft = await api.create_draft(
+ request=CatalogDraftRequest(
+ idempotency_key="batch-of-three",
+ base_release_id=int(current.id),
+ changes=(
+ CatalogChangeRequest(
+ type=CatalogChangeType.ASSIGN_ACTION_LEVEL,
+ action_code="edit",
+ level=3,
+ ),
+ CatalogChangeRequest(
+ type=CatalogChangeType.ASSIGN_ACTION_LEVEL,
+ action_code="rename",
+ level=4,
+ ),
+ CatalogChangeRequest(
+ type=CatalogChangeType.SET_ACTION_ACTIVE,
+ action_code="unpublish",
+ active=False,
+ ),
+ ),
+ ),
+ operator_id=7,
+ )
+
+ async with session_factory() as session:
+ rows = list(
+ (
+ await session.execute(
+ select(PermissionAction).where(PermissionAction.catalog_release_id == draft["draft_id"])
+ )
+ ).scalars()
+ )
+ by_code = {row.code: row for row in rows}
+ assert by_code["edit"].level == 3
+ assert by_code["rename"].level == 4
+ assert by_code["unpublish"].active is False
+ # Untouched actions keep the base release's values.
+ assert by_code["delete"].level == 4
+
+
+async def test_a_later_change_in_the_batch_wins_over_an_earlier_one(
+ session_factory: SessionFactory,
+) -> None:
+ fga = InMemoryCatalogFGA()
+ marker = FakeCatalogMarker()
+ current = await _seed_current(session_factory, fga)
+ api = _api(session_factory, fga, marker)
+
+ draft = await api.create_draft(
+ request=CatalogDraftRequest(
+ idempotency_key="batch-overwrite",
+ base_release_id=int(current.id),
+ changes=(
+ CatalogChangeRequest(
+ type=CatalogChangeType.ASSIGN_ACTION_LEVEL,
+ action_code="edit",
+ level=3,
+ ),
+ CatalogChangeRequest(
+ type=CatalogChangeType.ASSIGN_ACTION_LEVEL,
+ action_code="edit",
+ level=4,
+ ),
+ ),
+ ),
+ operator_id=7,
+ )
+
+ async with session_factory() as session:
+ row = (
+ await session.execute(
+ select(PermissionAction).where(
+ PermissionAction.catalog_release_id == draft["draft_id"],
+ PermissionAction.code == "edit",
+ )
+ )
+ ).scalar_one()
+ assert row.level == 4
diff --git a/src/frontend/platform/public/locales/en-US/permission.json b/src/frontend/platform/public/locales/en-US/permission.json
index 7bd8c808d1..cf004a2b06 100644
--- a/src/frontend/platform/public/locales/en-US/permission.json
+++ b/src/frontend/platform/public/locales/en-US/permission.json
@@ -68,7 +68,12 @@
"change": "Change action level",
"active": "Action status",
"inactive": "Inactive",
- "empty": "No actions in this area"
+ "empty": "No actions in this area",
+ "pendingChanges_one": "1 change is not published yet",
+ "pendingChanges_other": "{{count}} changes are not published yet",
+ "publishChanges": "Publish changes",
+ "discardChanges": "Discard changes",
+ "draftFailed": "Could not prepare the change draft. Try again."
},
"model": {
"title": "Permission Model",
diff --git a/src/frontend/platform/public/locales/ja/permission.json b/src/frontend/platform/public/locales/ja/permission.json
index c2bf46a369..90aba80ed6 100644
--- a/src/frontend/platform/public/locales/ja/permission.json
+++ b/src/frontend/platform/public/locales/ja/permission.json
@@ -68,7 +68,12 @@
"change": "アクションレベルを変更",
"active": "アクション状態",
"inactive": "無効",
- "empty": "この領域にアクションはありません"
+ "empty": "この領域にアクションはありません",
+ "pendingChanges_one": "未公開の変更が 1 件あります",
+ "pendingChanges_other": "未公開の変更が {{count}} 件あります",
+ "publishChanges": "変更を公開",
+ "discardChanges": "変更を破棄",
+ "draftFailed": "変更ドラフトの作成に失敗しました。再試行してください。"
},
"model": {
"title": "権限モデル",
diff --git a/src/frontend/platform/public/locales/zh-Hans/permission.json b/src/frontend/platform/public/locales/zh-Hans/permission.json
index 9ceedf189b..217dc017cf 100644
--- a/src/frontend/platform/public/locales/zh-Hans/permission.json
+++ b/src/frontend/platform/public/locales/zh-Hans/permission.json
@@ -68,7 +68,12 @@
"change": "调整动作等级",
"active": "动作状态",
"inactive": "已停用",
- "empty": "此区域暂无动作"
+ "empty": "此区域暂无动作",
+ "pendingChanges_one": "有 1 项改动尚未发布",
+ "pendingChanges_other": "有 {{count}} 项改动尚未发布",
+ "publishChanges": "发布更改",
+ "discardChanges": "放弃更改",
+ "draftFailed": "生成变更草案失败,请重试"
},
"model": {
"title": "权限模型",
diff --git a/src/frontend/platform/src/controllers/API/permission.ts b/src/frontend/platform/src/controllers/API/permission.ts
index 9e79f50b13..2651852a12 100644
--- a/src/frontend/platform/src/controllers/API/permission.ts
+++ b/src/frontend/platform/src/controllers/API/permission.ts
@@ -83,7 +83,7 @@ export interface PermissionCatalogChange {
export interface CreatePermissionCatalogDraftRequest {
idempotency_key: string
base_release_id: number
- change: PermissionCatalogChange
+ changes: PermissionCatalogChange[]
}
export interface PermissionCatalogImpact {
diff --git a/src/frontend/platform/src/pages/SystemPage/components/RolesAndPermissions.tsx b/src/frontend/platform/src/pages/SystemPage/components/RolesAndPermissions.tsx
index 07c1de9029..667c4ef7f0 100644
--- a/src/frontend/platform/src/pages/SystemPage/components/RolesAndPermissions.tsx
+++ b/src/frontend/platform/src/pages/SystemPage/components/RolesAndPermissions.tsx
@@ -68,7 +68,7 @@ interface ModelCatalogPanelProps {
selectedModelKey: string | null
onSelectModel: (modelKey: string) => void
onCreateDraft: (
- change: PermissionCatalogChange,
+ changes: PermissionCatalogChange[],
) => Promise
onReviewImpact: (draft: PermissionCatalogDraft) => void
}
@@ -205,13 +205,13 @@ export function RolesAndPermissions() {
}, [isPlatformSuperAdmin, loadCatalog])
const handleCreateDraft = async (
- change: PermissionCatalogChange,
+ changes: PermissionCatalogChange[],
): Promise => {
if (!catalog) throw new Error("permission Catalog is not loaded")
return await createPermissionCatalogDraftApi({
idempotency_key: createIdempotencyKey("catalog-draft"),
base_release_id: catalog.id,
- change,
+ changes,
})
}
diff --git a/src/frontend/platform/src/pages/SystemPage/components/permission/ActionLevelBoard.tsx b/src/frontend/platform/src/pages/SystemPage/components/permission/ActionLevelBoard.tsx
index d5219ea3bc..83ba86c216 100644
--- a/src/frontend/platform/src/pages/SystemPage/components/permission/ActionLevelBoard.tsx
+++ b/src/frontend/platform/src/pages/SystemPage/components/permission/ActionLevelBoard.tsx
@@ -15,7 +15,7 @@ interface ActionLevelBoardProps {
actions: PermissionCatalogAction[]
disabled?: boolean
onCreateDraft: (
- change: PermissionCatalogChange,
+ changes: PermissionCatalogChange[],
) => Promise
onReviewImpact: (draft: PermissionCatalogDraft) => void
}
@@ -50,10 +50,10 @@ export function ActionLevelBoard({
const normalizedActions = useMemo(() => uniqueActions(actions), [actions])
const [levels, setLevels] = useState>({})
const [activeStates, setActiveStates] = useState>({})
- const [pendingCode, setPendingCode] = useState(null)
- const [draft, setDraft] = useState(null)
+ const [submitting, setSubmitting] = useState(false)
+ const [draftFailed, setDraftFailed] = useState(false)
- useEffect(() => {
+ const resetToRelease = () => {
setLevels(
Object.fromEntries(
normalizedActions.map((action) => [action.code, action.level]),
@@ -64,56 +64,60 @@ export function ActionLevelBoard({
normalizedActions.map((action) => [action.code, action.active]),
),
)
- setDraft(null)
- }, [normalizedActions])
+ setDraftFailed(false)
+ }
- const createDraft = async (
- actionCode: string,
- change: PermissionCatalogChange,
- applyLocalState: () => void,
- ) => {
- if (disabled || pendingCode) return
- setPendingCode(actionCode)
- try {
- const nextDraft = await onCreateDraft(change)
- applyLocalState()
- setDraft(nextDraft)
- } finally {
- setPendingCode(null)
+ useEffect(resetToRelease, [normalizedActions])
+
+ // Derived from the diff against the published release rather than accumulated
+ // per edit, so moving a card back where it came from drops the change instead
+ // of queueing a second one.
+ const pendingChanges = useMemo(() => {
+ const changes: PermissionCatalogChange[] = []
+ for (const action of normalizedActions) {
+ const level = levels[action.code]
+ if (level !== undefined && level !== action.level) {
+ changes.push({
+ type: "ASSIGN_ACTION_LEVEL",
+ action_code: action.code,
+ level,
+ })
+ }
+ const active = activeStates[action.code]
+ if (active !== undefined && active !== action.active) {
+ changes.push({
+ type: "SET_ACTION_ACTIVE",
+ action_code: action.code,
+ active,
+ })
+ }
}
- }
+ return changes
+ }, [normalizedActions, levels, activeStates])
- const handleLevelChange = (
- actionCode: string,
- level: ActionLevelValue,
- ) => {
- if (levels[actionCode] === level) return
- void createDraft(
- actionCode,
- {
- type: "ASSIGN_ACTION_LEVEL",
- action_code: actionCode,
- level,
- },
- () => setLevels((current) => ({ ...current, [actionCode]: level })),
- )
+ const handleLevelChange = (actionCode: string, level: ActionLevelValue) => {
+ if (disabled || submitting || levels[actionCode] === level) return
+ setDraftFailed(false)
+ setLevels((current) => ({ ...current, [actionCode]: level }))
}
const handleActiveChange = (actionCode: string, active: boolean) => {
- if (activeStates[actionCode] === active) return
- void createDraft(
- actionCode,
- {
- type: "SET_ACTION_ACTIVE",
- action_code: actionCode,
- active,
- },
- () =>
- setActiveStates((current) => ({
- ...current,
- [actionCode]: active,
- })),
- )
+ if (disabled || submitting || activeStates[actionCode] === active) return
+ setDraftFailed(false)
+ setActiveStates((current) => ({ ...current, [actionCode]: active }))
+ }
+
+ const handlePublishChanges = async () => {
+ if (submitting || pendingChanges.length === 0) return
+ setSubmitting(true)
+ setDraftFailed(false)
+ try {
+ onReviewImpact(await onCreateDraft(pendingChanges))
+ } catch {
+ setDraftFailed(true)
+ } finally {
+ setSubmitting(false)
+ }
}
return (
@@ -130,32 +134,50 @@ export function ActionLevelBoard({
{t("actionLevel.description")}
-
+
+
+
+
- {draft && (
+ {pendingChanges.length > 0 && (
- {t("impact.pending", {
- resources: draft.impact.resource_count,
- grants: draft.impact.grant_count,
+ {t("actionLevel.pendingChanges", {
+ count: pendingChanges.length,
})}
)}
+ {draftFailed && (
+
+ {t("actionLevel.draftFailed")}
+
+ )}
+
{LEVELS.map((level) => {
const key = levelKey(level)
@@ -202,20 +224,19 @@ export function ActionLevelBoard({
{zoneActions.map((action) => {
const active = activeStates[action.code] ?? action.active
- const pending = pendingCode === action.code
return (
{
event.dataTransfer.effectAllowed = "move"
event.dataTransfer.setData("text/plain", action.code)
}}
className={cn(
"rounded-lg border bg-background p-3 shadow-sm transition-opacity",
- pending && "opacity-60",
+ submitting && "opacity-60",
)}
>
@@ -236,7 +257,7 @@ export function ActionLevelBoard({
handleActiveChange(action.code, checked)
}
@@ -267,7 +288,7 @@ export function ActionLevelBoard({