From 10bb4b3b458ea9bd57e382e076ed7dd61f6af721 Mon Sep 17 00:00:00 2001 From: GuoQing Zhang Date: Thu, 20 Aug 2026 16:44:31 +0800 Subject: [PATCH 1/4] fix(permission): clarify catalog publish impact --- .../permission/application/catalog_api.py | 75 ++++++ .../bisheng/permission/domain/schemas/f048.py | 22 ++ .../domain/services/catalog_service.py | 37 ++- .../permission/test_f048_catalog_runtime.py | 71 ++++++ .../public/locales/en-US/permission.json | 18 ++ .../public/locales/ja/permission.json | 18 ++ .../public/locales/zh-Hans/permission.json | 18 ++ .../src/controllers/API/permission.ts | 22 ++ .../components/permission/ImpactDialog.tsx | 223 ++++++++++++++---- .../src/test/f048ModelEditor.test.tsx | 40 +++- 10 files changed, 487 insertions(+), 57 deletions(-) diff --git a/src/backend/bisheng/permission/application/catalog_api.py b/src/backend/bisheng/permission/application/catalog_api.py index c60f68b801..9ea49d323f 100644 --- a/src/backend/bisheng/permission/application/catalog_api.py +++ b/src/backend/bisheng/permission/application/catalog_api.py @@ -59,10 +59,12 @@ derive_action_release, ) from bisheng.permission.domain.services.catalog_service import ( + CatalogActionChangeSummary, CatalogCommitUnknownError, CatalogDraftBuildInput, CatalogDraftSnapshot, CatalogImpactSummary, + CatalogModelChangeSummary, CatalogPublishContext, CatalogService, CatalogTupleChange, @@ -1086,6 +1088,53 @@ async def analyze_draft( revocation_count = sum( len(before - after) * len(sources_by_grant.get(int(row.id), ())) for row, before, after in affected ) + affected_assignees_by_model: dict[str, int] = {} + for row, _, _ in affected: + affected_assignees_by_model[row.model_key] = affected_assignees_by_model.get(row.model_key, 0) + len( + sources_by_grant.get(int(row.id), ()) + ) + before_action_by_code = {action.code: action for action in before_actions.actions} + after_action_by_code = {action.code: action for action in after_actions.actions} + action_changes = tuple( + CatalogActionChangeSummary( + action_code=action_code, + action_name=(after_action_by_code.get(action_code) or before_action_by_code[action_code]).name, + before_level=( + before_action_by_code[action_code].level if action_code in before_action_by_code else None + ), + after_level=(after_action_by_code[action_code].level if action_code in after_action_by_code else None), + before_active=( + before_action_by_code[action_code].active if action_code in before_action_by_code else False + ), + after_active=( + after_action_by_code[action_code].active if action_code in after_action_by_code else False + ), + ) + for action_code in action_impact.changed_action_codes + ) + model_changes = tuple( + CatalogModelChangeSummary( + model_key=model_key, + model_name=(after_by_key.get(model_key) or before_by_key[model_key]).name, + kind=(after_by_key.get(model_key) or before_by_key[model_key]).kind, + before_level=(before_by_key[model_key].derived_level if model_key in before_by_key else None), + after_level=(after_by_key[model_key].derived_level if model_key in after_by_key else None), + added_action_codes=tuple( + sorted( + set(after_by_key[model_key].action_codes if model_key in after_by_key else ()) + - set(before_by_key[model_key].action_codes if model_key in before_by_key else ()) + ) + ), + removed_action_codes=tuple( + sorted( + set(before_by_key[model_key].action_codes if model_key in before_by_key else ()) + - set(after_by_key[model_key].action_codes if model_key in after_by_key else ()) + ) + ), + affected_assignee_count=affected_assignees_by_model.get(model_key, 0), + ) + for model_key in model_impact.changed_model_keys + ) source_signatures = { int(row.id): tuple( ( @@ -1120,6 +1169,8 @@ async def analyze_draft( assignee_count=assignee_count, expansion_count=expansion_count, revocation_count=revocation_count, + action_changes=action_changes, + model_changes=model_changes, blockers=(), ) @@ -1570,6 +1621,30 @@ async def _draft_payload( "assignee_count": impact.assignee_count, "expansion_count": impact.expansion_count, "revocation_count": impact.revocation_count, + "action_changes": [ + { + "action_code": change.action_code, + "action_name": change.action_name, + "before_level": change.before_level, + "after_level": change.after_level, + "before_active": change.before_active, + "after_active": change.after_active, + } + for change in impact.action_changes + ], + "model_changes": [ + { + "model_key": change.model_key, + "model_name": change.model_name, + "kind": change.kind, + "before_level": change.before_level, + "after_level": change.after_level, + "added_action_codes": list(change.added_action_codes), + "removed_action_codes": list(change.removed_action_codes), + "affected_assignee_count": change.affected_assignee_count, + } + for change in impact.model_changes + ], "blockers": sorted(set(draft.blockers) | set(impact.blockers)), "expires_at": _as_utc(expires_at).isoformat(), }, diff --git a/src/backend/bisheng/permission/domain/schemas/f048.py b/src/backend/bisheng/permission/domain/schemas/f048.py index 49267ffb9a..9e442a6bf9 100644 --- a/src/backend/bisheng/permission/domain/schemas/f048.py +++ b/src/backend/bisheng/permission/domain/schemas/f048.py @@ -198,6 +198,26 @@ class CatalogDraftRequest(StrictRequestModel): changes: tuple[CatalogChangeRequest, ...] = Field(min_length=1, max_length=50) +class CatalogActionChangeDTO(BaseModel): + action_code: str + action_name: str + before_level: PermissionActionLevel | None = None + after_level: PermissionActionLevel | None = None + before_active: bool + after_active: bool + + +class CatalogModelChangeDTO(BaseModel): + model_key: str + model_name: str + kind: Literal["STANDARD", "CUSTOM"] + before_level: PermissionActionLevel | None = None + after_level: PermissionActionLevel | None = None + added_action_codes: tuple[str, ...] = () + removed_action_codes: tuple[str, ...] = () + affected_assignee_count: int = Field(ge=0) + + class CatalogImpactDTO(BaseModel): checksum: str = Field(min_length=64, max_length=64) resource_count: int = Field(ge=0) @@ -205,6 +225,8 @@ class CatalogImpactDTO(BaseModel): assignee_count: int = Field(ge=0) expansion_count: int = Field(ge=0) revocation_count: int = Field(ge=0) + action_changes: tuple[CatalogActionChangeDTO, ...] = () + model_changes: tuple[CatalogModelChangeDTO, ...] = () blockers: tuple[str, ...] = () expires_at: datetime diff --git a/src/backend/bisheng/permission/domain/services/catalog_service.py b/src/backend/bisheng/permission/domain/services/catalog_service.py index 15e3342798..ee07beb4e1 100644 --- a/src/backend/bisheng/permission/domain/services/catalog_service.py +++ b/src/backend/bisheng/permission/domain/services/catalog_service.py @@ -44,6 +44,32 @@ class CatalogCommitUnknownError(RuntimeError): """The active-pointer write may or may not have reached OpenFGA.""" +@dataclass(frozen=True, slots=True) +class CatalogActionChangeSummary: + """One operator-authored action change rendered in the impact review.""" + + action_code: str + action_name: str + before_level: int | None + after_level: int | None + before_active: bool + after_active: bool + + +@dataclass(frozen=True, slots=True) +class CatalogModelChangeSummary: + """One derived model change and its existing authorization impact.""" + + model_key: str + model_name: str + kind: str + before_level: int | None + after_level: int | None + added_action_codes: tuple[str, ...] + removed_action_codes: tuple[str, ...] + affected_assignee_count: int = 0 + + @dataclass(frozen=True, slots=True) class CatalogImpactSummary: """Cross-tenant impact aggregate bound to one complete draft.""" @@ -54,6 +80,8 @@ class CatalogImpactSummary: assignee_count: int expansion_count: int revocation_count: int + action_changes: tuple[CatalogActionChangeSummary, ...] = () + model_changes: tuple[CatalogModelChangeSummary, ...] = () blockers: tuple[str, ...] = () @@ -265,16 +293,11 @@ async def build_draft( ) before_by_key = {model.model_key: model for model in build.before_models.models} after_keys = {model.model_key for model in model_release.models} - deleted_models = tuple( - before_by_key[model_key] - for model_key in sorted(set(before_by_key) - after_keys) - ) + deleted_models = tuple(before_by_key[model_key] for model_key in sorted(set(before_by_key) - after_keys)) for model in deleted_models: references = build.model_reference_summaries.get(model.model_key) if references is None: - raise PermissionModelStateConflictError( - msg=f"Model reference audit is missing: {model.model_key}" - ) + raise PermissionModelStateConflictError(msg=f"Model reference audit is missing: {model.model_key}") try: ensure_model_deletable(model, references=references) except ValueError as exc: diff --git a/src/backend/test/permission/test_f048_catalog_runtime.py b/src/backend/test/permission/test_f048_catalog_runtime.py index 6ab50a91ec..f2d9e8e0ee 100644 --- a/src/backend/test/permission/test_f048_catalog_runtime.py +++ b/src/backend/test/permission/test_f048_catalog_runtime.py @@ -451,6 +451,74 @@ async def test_action_level_draft_rebuilds_every_standard_and_custom_model( assert "edit" in by_model["collaborator"] collaborator = next(row for row in model_rows if row.model_key == "collaborator") assert collaborator.derived_level == 3 + assert draft["impact"]["action_changes"] == [ + { + "action_code": "edit", + "action_name": "edit", + "before_level": 2, + "after_level": 3, + "before_active": True, + "after_active": True, + } + ] + assert draft["impact"]["model_changes"] == [ + { + "model_key": "collaborator", + "model_name": "协作者", + "kind": "CUSTOM", + "before_level": 2, + "after_level": 3, + "added_action_codes": [], + "removed_action_codes": [], + "affected_assignee_count": 0, + }, + { + "model_key": "editor", + "model_name": "编辑者", + "kind": "STANDARD", + "before_level": 2, + "after_level": 2, + "added_action_codes": [], + "removed_action_codes": ["edit"], + "affected_assignee_count": 0, + }, + ] + + +async def test_action_level_draft_reports_custom_model_level_only_change( + 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="raise-edit-to-owner", + base_release_id=int(current.id), + changes=( + CatalogChangeRequest( + type=CatalogChangeType.ASSIGN_ACTION_LEVEL, + action_code="edit", + level=4, + ), + ), + ), + operator_id=7, + ) + + custom_change = next(change for change in draft["impact"]["model_changes"] if change["model_key"] == "collaborator") + assert custom_change == { + "model_key": "collaborator", + "model_name": "协作者", + "kind": "CUSTOM", + "before_level": 2, + "after_level": 4, + "added_action_codes": [], + "removed_action_codes": [], + "affected_assignee_count": 0, + } async def test_catalog_publish_allows_visibility_only_grant_after_action_level_change( @@ -518,6 +586,9 @@ async def test_catalog_publish_allows_visibility_only_grant_after_action_level_c assert impact["assignee_count"] == 1 assert impact["expansion_count"] == 0 assert impact["revocation_count"] == 1 + viewer_change = next(change for change in impact["model_changes"] if change["model_key"] == "viewer") + assert viewer_change["removed_action_codes"] == ["download"] + assert viewer_change["affected_assignee_count"] == 1 assert impact["blockers"] == [] result = await api.publish_draft( draft_id=draft["draft_id"], diff --git a/src/frontend/platform/public/locales/en-US/permission.json b/src/frontend/platform/public/locales/en-US/permission.json index 4ab63df6df..be283ab3a2 100644 --- a/src/frontend/platform/public/locales/en-US/permission.json +++ b/src/frontend/platform/public/locales/en-US/permission.json @@ -123,6 +123,24 @@ "impact": { "title": "Confirm Publish Impact", "description": "Review the affected authorization data before publishing.", + "changeTitle": "Changes in this release", + "actionLevelChanged": "{{name}}: {{from}} → {{to}}", + "actionEnabled": "{{name}}: enabled", + "actionDisabled": "{{name}}: disabled", + "actionConfigurationChanged": "{{name}}: action configuration changed", + "affectedRecords": "Affected authorization records", + "recordUnit": "records", + "recordDescription": "The action permissions on these existing authorizations will change after publishing.", + "noRecordChanges": "No existing authorization records will have action permission changes.", + "modelChanges": "Permission model impact", + "modelAffectedRecords": "Action permissions will change on {{count}} authorization records", + "actionsAdded": "Actions gained: {{actions}}", + "actionsRemoved": "Actions lost: {{actions}}", + "modelLevelChanged": "Model level: {{from}} → {{to}}", + "customLevelOnly": "Existing action permissions stay the same, but future grant hierarchy will change.", + "modelConfigurationChanged": "The model configuration will change.", + "unlistedModelsUnchanged": "Permission models not listed here remain unchanged.", + "actionSeparator": ", ", "resources": "Resources", "grants": "Grants", "assignees": "Assignees", diff --git a/src/frontend/platform/public/locales/ja/permission.json b/src/frontend/platform/public/locales/ja/permission.json index da3b64aa2c..fec23129ec 100644 --- a/src/frontend/platform/public/locales/ja/permission.json +++ b/src/frontend/platform/public/locales/ja/permission.json @@ -123,6 +123,24 @@ "impact": { "title": "公開影響の確認", "description": "公開前に影響する権限データを確認してください。", + "changeTitle": "今回の変更", + "actionLevelChanged": "{{name}}:{{from}} → {{to}}", + "actionEnabled": "{{name}}:有効化", + "actionDisabled": "{{name}}:無効化", + "actionConfigurationChanged": "{{name}}:操作設定が変更されます", + "affectedRecords": "影響する権限付与レコード", + "recordUnit": "件", + "recordDescription": "公開後、これらの既存の権限付与に含まれる操作権限が変更されます。", + "noRecordChanges": "操作権限が変更される既存の権限付与レコードはありません。", + "modelChanges": "権限モデルへの影響", + "modelAffectedRecords": "{{count}} 件の権限付与レコードで操作権限が変更されます", + "actionsAdded": "追加される操作:{{actions}}", + "actionsRemoved": "失われる操作:{{actions}}", + "modelLevelChanged": "モデルレベル:{{from}} → {{to}}", + "customLevelOnly": "既存の操作権限は変わりませんが、今後の付与階層が変更されます。", + "modelConfigurationChanged": "モデル設定が変更されます。", + "unlistedModelsUnchanged": "ここに表示されていない権限モデルは変更されません。", + "actionSeparator": "、", "resources": "リソース", "grants": "付与", "assignees": "付与対象", diff --git a/src/frontend/platform/public/locales/zh-Hans/permission.json b/src/frontend/platform/public/locales/zh-Hans/permission.json index 51720f982c..e5439d3701 100644 --- a/src/frontend/platform/public/locales/zh-Hans/permission.json +++ b/src/frontend/platform/public/locales/zh-Hans/permission.json @@ -123,6 +123,24 @@ "impact": { "title": "发布影响确认", "description": "请核对本次变更影响后再发布。", + "changeTitle": "本次变更", + "actionLevelChanged": "{{name}}:{{from}} → {{to}}", + "actionEnabled": "{{name}}:启用", + "actionDisabled": "{{name}}:停用", + "actionConfigurationChanged": "{{name}}:动作配置发生变化", + "affectedRecords": "受影响授权记录", + "recordUnit": "条", + "recordDescription": "这些现有授权的动作权限将在发布后发生变化。", + "noRecordChanges": "没有现有授权记录的动作权限发生变化。", + "modelChanges": "权限模型影响", + "modelAffectedRecords": "{{count}} 条授权记录的动作权限发生变化", + "actionsAdded": "获得动作:{{actions}}", + "actionsRemoved": "失去动作:{{actions}}", + "modelLevelChanged": "模型等级:{{from}} → {{to}}", + "customLevelOnly": "已有动作权限不变,但后续授权层级将发生变化。", + "modelConfigurationChanged": "模型配置发生变化。", + "unlistedModelsUnchanged": "未列出的权限模型保持不变。", + "actionSeparator": "、", "resources": "资源", "grants": "授权", "assignees": "授权对象", diff --git a/src/frontend/platform/src/controllers/API/permission.ts b/src/frontend/platform/src/controllers/API/permission.ts index 84fe176074..65316a405b 100644 --- a/src/frontend/platform/src/controllers/API/permission.ts +++ b/src/frontend/platform/src/controllers/API/permission.ts @@ -94,10 +94,32 @@ export interface PermissionCatalogImpact { assignee_count: number expansion_count: number revocation_count: number + action_changes?: PermissionCatalogActionChange[] + model_changes?: PermissionCatalogModelChange[] blockers: string[] expires_at: string } +export interface PermissionCatalogActionChange { + action_code: string + action_name: string + before_level: PermissionActionLevel | null + after_level: PermissionActionLevel | null + before_active: boolean + after_active: boolean +} + +export interface PermissionCatalogModelChange { + model_key: string + model_name: string + kind: PermissionModelKind + before_level: PermissionActionLevel | null + after_level: PermissionActionLevel | null + added_action_codes: string[] + removed_action_codes: string[] + affected_assignee_count: number +} + export interface PermissionCatalogDraft { draft_id: number base_release_id: number 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 90a584076d..59cf50dc19 100644 --- a/src/frontend/platform/src/pages/SystemPage/components/permission/ImpactDialog.tsx +++ b/src/frontend/platform/src/pages/SystemPage/components/permission/ImpactDialog.tsx @@ -9,14 +9,17 @@ import { } from "@/components/bs-ui/dialog" import type { PermissionCatalogAction, + PermissionCatalogActionChange, PermissionCatalogDraft, PermissionCatalogModel, + PermissionCatalogModelChange, PublishPermissionCatalogDraftRequest, } from "@/controllers/API/permission" import { formatDate } from "@/util/utils" import { AlertTriangle } from "lucide-react" import { useState } from "react" import { useTranslation } from "react-i18next" +import { actionLabel } from "./actionLabels" import { formatBlockerMessage } from "./blockerMessages" interface ImpactDialogProps { @@ -35,26 +38,6 @@ interface ImpactDialogProps { now?: Date } -interface ImpactMetricProps { - label: string - value: number - testId: string -} - -function ImpactMetric({ label, value, testId }: ImpactMetricProps) { - return ( -
-
{label}
-
- {value} -
-
- ) -} - // 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. @@ -87,6 +70,58 @@ export function ImpactDialog({ const expired = new Date(draft.impact.expires_at).getTime() <= now.getTime() const blocked = draft.impact.blockers.length > 0 + const actionChanges = draft.impact.action_changes ?? [] + const modelChanges = draft.impact.model_changes ?? [] + + const levelName = (level: number | null) => + level === null + ? t("actionLevel.unassigned") + : t("actionLevel.level", { level }) + + const displayActionName = (code: string, fallback?: string) => + actionLabel( + t, + code, + actions?.find((action) => action.code === code)?.name ?? fallback, + ) + + const displayModelName = (change: PermissionCatalogModelChange) => { + if ( + change.kind === "STANDARD" && + ["viewer", "editor", "manager", "owner"].includes(change.model_key) + ) { + return t(`level.${change.model_key}`) + } + return change.model_name + } + + const actionChangeLines = (change: PermissionCatalogActionChange) => { + const name = displayActionName(change.action_code, change.action_name) + const lines: string[] = [] + if (change.before_level !== change.after_level) { + lines.push( + t("impact.actionLevelChanged", { + name, + from: levelName(change.before_level), + to: levelName(change.after_level), + }), + ) + } + if (change.before_active !== change.after_active) { + lines.push( + t( + change.after_active + ? "impact.actionEnabled" + : "impact.actionDisabled", + { name }, + ), + ) + } + if (lines.length === 0) { + lines.push(t("impact.actionConfigurationChanged", { name })) + } + return lines + } const handlePublish = async () => { if (publishing || expired || blocked) return @@ -114,33 +149,127 @@ export function ImpactDialog({ {t("impact.description")} -
- - - - - -
+ {actionChanges.length > 0 && ( +
+

+ {t("impact.changeTitle")} +

+ +
+ )} + +
+

+ {t("impact.affectedRecords")} +

+

+ {draft.impact.assignee_count} + + {t("impact.recordUnit")} + +

+

+ {draft.impact.assignee_count > 0 + ? t("impact.recordDescription") + : t("impact.noRecordChanges")} +

+
+ + {modelChanges.length > 0 && ( +
+

+ {t("impact.modelChanges")} +

+
+ {modelChanges.map((change) => { + const levelChanged = change.before_level !== change.after_level + const actionsChanged = + change.added_action_codes.length > 0 || + change.removed_action_codes.length > 0 + return ( +
+
+

+ {displayModelName(change)} +

+ + {t( + change.kind === "CUSTOM" + ? "model.kind.custom" + : "model.kind.standard", + )} + +
+
    + {change.affected_assignee_count > 0 && ( +
  • + {t("impact.modelAffectedRecords", { + count: change.affected_assignee_count, + })} +
  • + )} + {change.added_action_codes.length > 0 && ( +
  • + {t("impact.actionsAdded", { + actions: change.added_action_codes + .map((code) => displayActionName(code)) + .join(t("impact.actionSeparator")), + })} +
  • + )} + {change.removed_action_codes.length > 0 && ( +
  • + {t("impact.actionsRemoved", { + actions: change.removed_action_codes + .map((code) => displayActionName(code)) + .join(t("impact.actionSeparator")), + })} +
  • + )} + {levelChanged && ( +
  • + {t("impact.modelLevelChanged", { + from: levelName(change.before_level), + to: levelName(change.after_level), + })} +
  • + )} + {change.kind === "CUSTOM" && + levelChanged && + !actionsChanged && ( +
  • {t("impact.customLevelOnly")}
  • + )} + {!levelChanged && !actionsChanged && ( +
  • {t("impact.modelConfigurationChanged")}
  • + )} +
+
+ ) + })} +
+

+ {t("impact.unlistedModelsUnchanged")} +

+
+ )} {blocked && (
{ />, ) - expect(screen.getByTestId("impact-resource-count")).toHaveTextContent("8") - expect(screen.getByTestId("impact-grant-count")).toHaveTextContent("5") expect(screen.getByTestId("impact-assignee-count")).toHaveTextContent("12") - expect(screen.getByTestId("impact-revocation-count")).toHaveTextContent("3") + expect(screen.getByText("impact.changeTitle")).toBeInTheDocument() + expect(screen.getByText("level.manager")).toBeInTheDocument() + expect(screen.getByText("Collaborator")).toBeInTheDocument() + expect(screen.getByText("impact.customLevelOnly")).toBeInTheDocument() + expect(screen.queryByText("impact.resources")).not.toBeInTheDocument() fireEvent.click(screen.getByRole("button", { name: "impact.publish" })) await waitFor(() => { From efe140b426c9c3df76e88d60e38f22589d57e079 Mon Sep 17 00:00:00 2001 From: GuoQing Zhang Date: Thu, 20 Aug 2026 17:14:27 +0800 Subject: [PATCH 2/4] fix(permission): clarify same-level grant conflict --- .../bisheng/common/errcode/permission.py | 5 +++ .../permission/application/catalog_api.py | 12 +++++++ .../domain/services/model_policy.py | 8 ++++- .../test/permission/test_f048_catalog_api.py | 2 ++ .../permission/test_f048_catalog_runtime.py | 33 +++++++++++++++++++ .../client/src/locales/en/api_errors.gen.json | 1 + .../client/src/locales/ja/api_errors.gen.json | 1 + .../src/locales/zh-Hans/api_errors.gen.json | 1 + .../packages/locales/src/api_errors/en.json | 1 + .../packages/locales/src/api_errors/ja.json | 1 + .../locales/src/api_errors/zh-Hans.json | 1 + .../public/locales/en-US/api_errors.json | 1 + .../public/locales/ja/api_errors.json | 1 + .../public/locales/zh-Hans/api_errors.json | 1 + .../permission/ActionLevelBoard.tsx | 22 ++++++++----- .../src/test/f048ActionLevelBoard.test.tsx | 23 +++++++++++++ 16 files changed, 104 insertions(+), 10 deletions(-) diff --git a/src/backend/bisheng/common/errcode/permission.py b/src/backend/bisheng/common/errcode/permission.py index 6be959e65d..ebdd35245d 100644 --- a/src/backend/bisheng/common/errcode/permission.py +++ b/src/backend/bisheng/common/errcode/permission.py @@ -117,3 +117,8 @@ class PermissionMutationTooLargeError(BaseErrorCode): class PermissionEnumerationIncompleteError(BaseErrorCode): Code: int = 25014 Msg: str = "Permission object enumeration did not complete" + + +class SameLevelGrantRequiresManagePermissionError(BaseErrorCode): + Code: int = 25015 + Msg: str = "Same-level grants require the manage_permission action" diff --git a/src/backend/bisheng/permission/application/catalog_api.py b/src/backend/bisheng/permission/application/catalog_api.py index 9ea49d323f..c98ba88dcd 100644 --- a/src/backend/bisheng/permission/application/catalog_api.py +++ b/src/backend/bisheng/permission/application/catalog_api.py @@ -24,6 +24,7 @@ PermissionProjectionFailedError, PermissionPublishNotReadyError, PermissionVersionConflictError, + SameLevelGrantRequiresManagePermissionError, ) from bisheng.core.context.tenant import bypass_tenant_filter from bisheng.core.database import get_async_db_session @@ -74,6 +75,7 @@ ModelReferenceSummary, PermissionModelImpact, PermissionModelRelease, + SameLevelGrantRequiresManagePermission, derive_permission_models, effective_model_action_codes, ) @@ -1567,6 +1569,11 @@ async def create_draft( ) except (InvalidCatalogActionError, ImmutableStandardModelError): raise + except SameLevelGrantRequiresManagePermission as exc: + raise SameLevelGrantRequiresManagePermissionError( + exception=exc, + msg=str(exc), + ) from exc except ValueError as exc: raise InvalidCatalogActionError( exception=exc, @@ -1818,6 +1825,11 @@ async def _apply_changes( custom_models=custom_by_key.values(), standard_allow_same_level=standard_policy, ) + except SameLevelGrantRequiresManagePermission as exc: + raise SameLevelGrantRequiresManagePermissionError( + exception=exc, + msg=str(exc), + ) from exc except ValueError as exc: if touched_standard_keys: raise ImmutableStandardModelError( diff --git a/src/backend/bisheng/permission/domain/services/model_policy.py b/src/backend/bisheng/permission/domain/services/model_policy.py index 1e342655f4..8c9363ad2b 100644 --- a/src/backend/bisheng/permission/domain/services/model_policy.py +++ b/src/backend/bisheng/permission/domain/services/model_policy.py @@ -20,6 +20,10 @@ STANDARD_MODEL_KEYS = frozenset(row[0] for row in STANDARD_MODEL_DEFINITIONS) +class SameLevelGrantRequiresManagePermission(ValueError): + """A model cannot delegate its tier without permission management.""" + + @dataclass(frozen=True, slots=True) class CustomModelSelection: """Administrator-owned explicit action selection.""" @@ -326,7 +330,9 @@ def with_allow_same_level( if not isinstance(allow_same_level, bool): raise ValueError("same-level policy must be boolean") if allow_same_level and "manage_permission" not in model.action_codes: - raise ValueError(f"model {model.model_key} requires manage_permission to allow same level") + raise SameLevelGrantRequiresManagePermission( + f"model {model.model_key} requires manage_permission to allow same level" + ) return replace(model, allow_same_level=allow_same_level) diff --git a/src/backend/test/permission/test_f048_catalog_api.py b/src/backend/test/permission/test_f048_catalog_api.py index abcf12d99a..08005ae405 100644 --- a/src/backend/test/permission/test_f048_catalog_api.py +++ b/src/backend/test/permission/test_f048_catalog_api.py @@ -12,6 +12,7 @@ ImmutableStandardModelError, InvalidCatalogActionError, PermissionVersionConflictError, + SameLevelGrantRequiresManagePermissionError, ) from bisheng.permission.api.dependencies import get_catalog_api from bisheng.permission.api.endpoints.catalog import router @@ -135,6 +136,7 @@ def test_catalog_semantic_errors_are_translated_to_unified_codes() -> None: cases = ( (InvalidCatalogActionError(), 25001), (ImmutableStandardModelError(), 25003), + (SameLevelGrantRequiresManagePermissionError(), 25015), ) for error, expected in cases: api = _CatalogApi() diff --git a/src/backend/test/permission/test_f048_catalog_runtime.py b/src/backend/test/permission/test_f048_catalog_runtime.py index f2d9e8e0ee..6d5678696b 100644 --- a/src/backend/test/permission/test_f048_catalog_runtime.py +++ b/src/backend/test/permission/test_f048_catalog_runtime.py @@ -15,6 +15,7 @@ from bisheng.common.errcode.permission import ( PermissionPublishNotReadyError, + SameLevelGrantRequiresManagePermissionError, ) from bisheng.core.context.tenant import bypass_tenant_filter from bisheng.core.openfga.authorization_model_f048 import ( @@ -521,6 +522,38 @@ async def test_action_level_draft_reports_custom_model_level_only_change( } +async def test_action_level_draft_reports_same_level_policy_conflict( + session_factory: SessionFactory, +) -> None: + fga = InMemoryCatalogFGA() + marker = FakeCatalogMarker() + current = await _seed_current(session_factory, fga) + api = _api(session_factory, fga, marker) + + with pytest.raises(SameLevelGrantRequiresManagePermissionError) as raised: + await api.create_draft( + request=CatalogDraftRequest( + idempotency_key="manager-same-level-conflict", + base_release_id=int(current.id), + changes=( + CatalogChangeRequest( + type=CatalogChangeType.SET_ALLOW_SAME_LEVEL, + model_key="manager", + allow_same_level=True, + ), + CatalogChangeRequest( + type=CatalogChangeType.ASSIGN_ACTION_LEVEL, + action_code="manage_permission", + level=4, + ), + ), + ), + operator_id=7, + ) + + assert raised.value.Code == 25015 + + async def test_catalog_publish_allows_visibility_only_grant_after_action_level_change( session_factory: SessionFactory, ) -> None: diff --git a/src/frontend/client/src/locales/en/api_errors.gen.json b/src/frontend/client/src/locales/en/api_errors.gen.json index 33d31fd155..6cf9961373 100644 --- a/src/frontend/client/src/locales/en/api_errors.gen.json +++ b/src/frontend/client/src/locales/en/api_errors.gen.json @@ -414,6 +414,7 @@ "25012": "The impact analysis expired. Start the publish again", "25013": "Too many permission changes at once. Split them into smaller batches", "25014": "The visible resource list was incomplete. Try again later", + "25015": "This permission model allows same-level grants but would lose the Manage permissions action. Turn off same-level grants before changing the action level", "90001": "You do not have permission to access the admin backend. Please contact the administrator to request access if needed.", "90002": "Your current role does not have permission to access the workbench. Please contact the administrator if needed.", "personIdAlreadyExists": "Person ID already exists", diff --git a/src/frontend/client/src/locales/ja/api_errors.gen.json b/src/frontend/client/src/locales/ja/api_errors.gen.json index e830a29d0f..b4dca4dd7d 100644 --- a/src/frontend/client/src/locales/ja/api_errors.gen.json +++ b/src/frontend/client/src/locales/ja/api_errors.gen.json @@ -414,6 +414,7 @@ "25012": "影響分析の有効期限が切れました。公開をやり直してください", "25013": "一度に変更する権限が多すぎます。分割して実行してください", "25014": "表示可能なリソース一覧を完全に取得できませんでした。しばらくしてから再試行してください", + "25015": "この権限モデルでは同一レベルの付与が許可されていますが、変更後は「権限管理」操作を失います。操作レベルを変更する前に、同一レベルの付与を無効にしてください", "90001": "現在のアカウントには管理バックエンドへのアクセス権限がありません。必要な場合は、管理者に連絡して権限を開通してください。", "90002": "現在のロールにはワークベンチへのアクセス権限がありません。必要な場合は管理者に連絡してください。", "personIdAlreadyExists": "Person ID は既に存在します", diff --git a/src/frontend/client/src/locales/zh-Hans/api_errors.gen.json b/src/frontend/client/src/locales/zh-Hans/api_errors.gen.json index 2fb60e242e..0d43211304 100644 --- a/src/frontend/client/src/locales/zh-Hans/api_errors.gen.json +++ b/src/frontend/client/src/locales/zh-Hans/api_errors.gen.json @@ -414,6 +414,7 @@ "25012": "影响分析已过期,请重新发起发布", "25013": "单次权限变更条目过多,请分批操作", "25014": "可见资源列表获取不完整,请稍后重试", + "25015": "当前权限模型开启了“允许同级授权”,但调整后将失去“权限管理”动作。请先关闭“允许同级授权”,再调整动作等级", "90001": "您当前角色没有访问管理后台的权限。如有需要,请联系管理员开通。", "90002": "您当前角色没有访问工作台的权限。如有需要,请联系管理员开通。", "personIdAlreadyExists": "人员 ID 已存在", diff --git a/src/frontend/packages/locales/src/api_errors/en.json b/src/frontend/packages/locales/src/api_errors/en.json index 33d31fd155..6cf9961373 100644 --- a/src/frontend/packages/locales/src/api_errors/en.json +++ b/src/frontend/packages/locales/src/api_errors/en.json @@ -414,6 +414,7 @@ "25012": "The impact analysis expired. Start the publish again", "25013": "Too many permission changes at once. Split them into smaller batches", "25014": "The visible resource list was incomplete. Try again later", + "25015": "This permission model allows same-level grants but would lose the Manage permissions action. Turn off same-level grants before changing the action level", "90001": "You do not have permission to access the admin backend. Please contact the administrator to request access if needed.", "90002": "Your current role does not have permission to access the workbench. Please contact the administrator if needed.", "personIdAlreadyExists": "Person ID already exists", diff --git a/src/frontend/packages/locales/src/api_errors/ja.json b/src/frontend/packages/locales/src/api_errors/ja.json index e830a29d0f..b4dca4dd7d 100644 --- a/src/frontend/packages/locales/src/api_errors/ja.json +++ b/src/frontend/packages/locales/src/api_errors/ja.json @@ -414,6 +414,7 @@ "25012": "影響分析の有効期限が切れました。公開をやり直してください", "25013": "一度に変更する権限が多すぎます。分割して実行してください", "25014": "表示可能なリソース一覧を完全に取得できませんでした。しばらくしてから再試行してください", + "25015": "この権限モデルでは同一レベルの付与が許可されていますが、変更後は「権限管理」操作を失います。操作レベルを変更する前に、同一レベルの付与を無効にしてください", "90001": "現在のアカウントには管理バックエンドへのアクセス権限がありません。必要な場合は、管理者に連絡して権限を開通してください。", "90002": "現在のロールにはワークベンチへのアクセス権限がありません。必要な場合は管理者に連絡してください。", "personIdAlreadyExists": "Person ID は既に存在します", diff --git a/src/frontend/packages/locales/src/api_errors/zh-Hans.json b/src/frontend/packages/locales/src/api_errors/zh-Hans.json index 2fb60e242e..0d43211304 100644 --- a/src/frontend/packages/locales/src/api_errors/zh-Hans.json +++ b/src/frontend/packages/locales/src/api_errors/zh-Hans.json @@ -414,6 +414,7 @@ "25012": "影响分析已过期,请重新发起发布", "25013": "单次权限变更条目过多,请分批操作", "25014": "可见资源列表获取不完整,请稍后重试", + "25015": "当前权限模型开启了“允许同级授权”,但调整后将失去“权限管理”动作。请先关闭“允许同级授权”,再调整动作等级", "90001": "您当前角色没有访问管理后台的权限。如有需要,请联系管理员开通。", "90002": "您当前角色没有访问工作台的权限。如有需要,请联系管理员开通。", "personIdAlreadyExists": "人员 ID 已存在", diff --git a/src/frontend/platform/public/locales/en-US/api_errors.json b/src/frontend/platform/public/locales/en-US/api_errors.json index 33d31fd155..6cf9961373 100644 --- a/src/frontend/platform/public/locales/en-US/api_errors.json +++ b/src/frontend/platform/public/locales/en-US/api_errors.json @@ -414,6 +414,7 @@ "25012": "The impact analysis expired. Start the publish again", "25013": "Too many permission changes at once. Split them into smaller batches", "25014": "The visible resource list was incomplete. Try again later", + "25015": "This permission model allows same-level grants but would lose the Manage permissions action. Turn off same-level grants before changing the action level", "90001": "You do not have permission to access the admin backend. Please contact the administrator to request access if needed.", "90002": "Your current role does not have permission to access the workbench. Please contact the administrator if needed.", "personIdAlreadyExists": "Person ID already exists", diff --git a/src/frontend/platform/public/locales/ja/api_errors.json b/src/frontend/platform/public/locales/ja/api_errors.json index e830a29d0f..b4dca4dd7d 100644 --- a/src/frontend/platform/public/locales/ja/api_errors.json +++ b/src/frontend/platform/public/locales/ja/api_errors.json @@ -414,6 +414,7 @@ "25012": "影響分析の有効期限が切れました。公開をやり直してください", "25013": "一度に変更する権限が多すぎます。分割して実行してください", "25014": "表示可能なリソース一覧を完全に取得できませんでした。しばらくしてから再試行してください", + "25015": "この権限モデルでは同一レベルの付与が許可されていますが、変更後は「権限管理」操作を失います。操作レベルを変更する前に、同一レベルの付与を無効にしてください", "90001": "現在のアカウントには管理バックエンドへのアクセス権限がありません。必要な場合は、管理者に連絡して権限を開通してください。", "90002": "現在のロールにはワークベンチへのアクセス権限がありません。必要な場合は管理者に連絡してください。", "personIdAlreadyExists": "Person ID は既に存在します", diff --git a/src/frontend/platform/public/locales/zh-Hans/api_errors.json b/src/frontend/platform/public/locales/zh-Hans/api_errors.json index 2fb60e242e..0d43211304 100644 --- a/src/frontend/platform/public/locales/zh-Hans/api_errors.json +++ b/src/frontend/platform/public/locales/zh-Hans/api_errors.json @@ -414,6 +414,7 @@ "25012": "影响分析已过期,请重新发起发布", "25013": "单次权限变更条目过多,请分批操作", "25014": "可见资源列表获取不完整,请稍后重试", + "25015": "当前权限模型开启了“允许同级授权”,但调整后将失去“权限管理”动作。请先关闭“允许同级授权”,再调整动作等级", "90001": "您当前角色没有访问管理后台的权限。如有需要,请联系管理员开通。", "90002": "您当前角色没有访问工作台的权限。如有需要,请联系管理员开通。", "personIdAlreadyExists": "人员 ID 已存在", 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 4ddd26186d..ad8c9095de 100644 --- a/src/frontend/platform/src/pages/SystemPage/components/permission/ActionLevelBoard.tsx +++ b/src/frontend/platform/src/pages/SystemPage/components/permission/ActionLevelBoard.tsx @@ -65,7 +65,7 @@ export function ActionLevelBoard({ const [levels, setLevels] = useState>({}) const [activeStates, setActiveStates] = useState>({}) const [submitting, setSubmitting] = useState(false) - const [draftFailed, setDraftFailed] = useState(false) + const [draftErrorMessage, setDraftErrorMessage] = useState(null) const [showChangeList, setShowChangeList] = useState(false) const [draggingCode, setDraggingCode] = useState(null) @@ -80,7 +80,7 @@ export function ActionLevelBoard({ normalizedActions.map((action) => [action.code, action.active]), ), ) - setDraftFailed(false) + setDraftErrorMessage(null) setShowChangeList(false) } @@ -150,24 +150,28 @@ export function ActionLevelBoard({ const handleLevelChange = (actionCode: string, level: ActionLevelValue) => { if (disabled || submitting || levels[actionCode] === level) return - setDraftFailed(false) + setDraftErrorMessage(null) setLevels((current) => ({ ...current, [actionCode]: level })) } const handleActiveChange = (actionCode: string, active: boolean) => { if (disabled || submitting || activeStates[actionCode] === active) return - setDraftFailed(false) + setDraftErrorMessage(null) setActiveStates((current) => ({ ...current, [actionCode]: active })) } const handlePublishChanges = async () => { if (submitting || pendingChanges.length === 0) return setSubmitting(true) - setDraftFailed(false) + setDraftErrorMessage(null) try { onReviewImpact(await onCreateDraft(pendingChanges)) - } catch { - setDraftFailed(true) + } catch (error) { + setDraftErrorMessage( + typeof error === "string" && error.trim() + ? error + : t("actionLevel.draftFailed"), + ) } finally { setSubmitting(false) } @@ -243,12 +247,12 @@ export function ActionLevelBoard({
)} - {draftFailed && ( + {draftErrorMessage && (

- {t("actionLevel.draftFailed")} + {draftErrorMessage}

)} diff --git a/src/frontend/platform/src/test/f048ActionLevelBoard.test.tsx b/src/frontend/platform/src/test/f048ActionLevelBoard.test.tsx index d55e4b01c6..8fec2082a8 100644 --- a/src/frontend/platform/src/test/f048ActionLevelBoard.test.tsx +++ b/src/frontend/platform/src/test/f048ActionLevelBoard.test.tsx @@ -194,6 +194,29 @@ describe("ActionLevelBoard", () => { expect(onReviewImpact).not.toHaveBeenCalled() }) + it("shows the localized business reason returned by draft validation", async () => { + onCreateDraft.mockRejectedValueOnce( + "Turn off same-level grants for Manager before moving permission management to a higher level.", + ) + render( + , + ) + + await selectMenuOption("actionLevel.change.edit", 3) + fireEvent.click( + screen.getByRole("button", { name: "actionLevel.publishChanges" }), + ) + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Turn off same-level grants for Manager", + ) + expect(onReviewImpact).not.toHaveBeenCalled() + }) + it("shows resource scope on demand and the inactive marker on the card", async () => { // The card carries only what the author scans for — name and on/off. The // scope is one hover away rather than a row of chips on every card. From 20a3a0bfce7234cdde3ad58008f420e42162dd16 Mon Sep 17 00:00:00 2001 From: GuoQing Zhang Date: Thu, 20 Aug 2026 17:15:38 +0800 Subject: [PATCH 3/4] fix(permission): hide inherited resource ids --- .../services/f048_permission_subject.py | 34 ++++++++++++----- .../test_f048_inherited_roster_display.py | 37 ++++++++++++++++++- .../permission/PermissionListTab.test.tsx | 4 ++ .../permission/PermissionListTab.tsx | 22 ++++++++++- .../client/src/locales/en/translation.json | 5 ++- .../client/src/locales/ja/translation.json | 5 ++- .../src/locales/zh-Hans/translation.json | 5 ++- .../public/locales/en-US/permission.json | 5 ++- .../public/locales/ja/permission.json | 5 ++- .../public/locales/zh-Hans/permission.json | 5 ++- .../bs-comp/permission/PermissionListTab.tsx | 20 +++++++++- .../src/test/f048PermissionRoster.test.tsx | 34 ++++++++++++++++- 12 files changed, 159 insertions(+), 22 deletions(-) diff --git a/src/backend/bisheng/tenant/domain/services/f048_permission_subject.py b/src/backend/bisheng/tenant/domain/services/f048_permission_subject.py index d1ece79811..32d8076bc4 100644 --- a/src/backend/bisheng/tenant/domain/services/f048_permission_subject.py +++ b/src/backend/bisheng/tenant/domain/services/f048_permission_subject.py @@ -207,26 +207,40 @@ async def resource_display_names( """Label the resources a grant can be inherited from. The permission layer knows a resource's identity, never its name, so the - roster reported inheritance as "knowledge_space:3377". Only the container - types can be a permission parent, and both live in the knowledge table. - Anything else resolves to nothing and the caller keeps showing the id. + business side resolves labels for both spaces and folders. Unknown or + missing resources stay unlabeled so callers can use a friendly generic + fallback without exposing the internal resource key. """ from bisheng.knowledge.domain.models.knowledge import KnowledgeDao + from bisheng.knowledge.domain.models.knowledge_file import KnowledgeFileDao knowledge_ids = [ int(resource_id) for resource_type, resource_id in resources if resource_type in {"knowledge_space", "knowledge_library"} and resource_id.isdigit() ] - if not knowledge_ids: - return {} - rows = await KnowledgeDao.aget_list_by_ids(knowledge_ids) - by_id = {int(row.id): row.name for row in rows or () if row.id is not None} - return { - (resource_type, resource_id): by_id[int(resource_id)] + folder_ids = [ + int(resource_id) + for resource_type, resource_id in resources + if resource_type == "folder" and resource_id.isdigit() + ] + knowledge_rows = await KnowledgeDao.aget_list_by_ids(knowledge_ids) if knowledge_ids else [] + folder_rows = await KnowledgeFileDao.aget_file_by_ids(folder_ids) if folder_ids else [] + knowledge_by_id = {int(row.id): row.name for row in knowledge_rows or () if row.id is not None} + folder_by_id = {int(row.id): row.file_name for row in folder_rows or () if row.id is not None} + labels = { + (resource_type, resource_id): knowledge_by_id[int(resource_id)] for resource_type, resource_id in resources if resource_type in {"knowledge_space", "knowledge_library"} and resource_id.isdigit() - and int(resource_id) in by_id + and int(resource_id) in knowledge_by_id } + labels.update( + { + (resource_type, resource_id): folder_by_id[int(resource_id)] + for resource_type, resource_id in resources + if resource_type == "folder" and resource_id.isdigit() and int(resource_id) in folder_by_id + } + ) + return labels diff --git a/src/backend/test/permission/test_f048_inherited_roster_display.py b/src/backend/test/permission/test_f048_inherited_roster_display.py index cd4cbb64e1..44097e9b1e 100644 --- a/src/backend/test/permission/test_f048_inherited_roster_display.py +++ b/src/backend/test/permission/test_f048_inherited_roster_display.py @@ -1,7 +1,5 @@ """Inherited roster rows: name the parent, and don't repeat its creator. -Two走查 findings on the "继承上级" view. - The roster reported inheritance as `knowledge_space:3377`, because the permission layer holds a resource's identity and never its label. Resource names are resolved the same way subject names already are — through the business side. @@ -13,7 +11,12 @@ from __future__ import annotations +from types import SimpleNamespace + from bisheng.permission.application.resource_api import _split_resource_key +from bisheng.tenant.domain.services.f048_permission_subject import ( + TenantPermissionSubjectDirectory, +) def test_split_resource_key() -> None: @@ -41,3 +44,33 @@ def __init__(self, source_type: str) -> None: assert [key for _, key in kept] == ["viewer", "viewer"] assert all(row.source_type != "CREATOR" for row, _ in kept) + + +async def test_resource_display_names_resolves_spaces_and_folders(monkeypatch) -> None: + from bisheng.knowledge.domain.models.knowledge import KnowledgeDao + from bisheng.knowledge.domain.models.knowledge_file import KnowledgeFileDao + + async def load_spaces(ids: list[int]): + assert ids == [3377] + return [SimpleNamespace(id=3377, name="Product Knowledge")] + + async def load_folders(ids: list[int]): + assert ids == [94661] + return [SimpleNamespace(id=94661, file_name="Release Notes")] + + monkeypatch.setattr(KnowledgeDao, "aget_list_by_ids", load_spaces) + monkeypatch.setattr(KnowledgeFileDao, "aget_file_by_ids", load_folders) + + labels = await TenantPermissionSubjectDirectory().resource_display_names( + ( + ("knowledge_space", "3377"), + ("folder", "94661"), + ("folder", "not-an-id"), + ("workflow", "wf-1"), + ) + ) + + assert labels == { + ("knowledge_space", "3377"): "Product Knowledge", + ("folder", "94661"): "Release Notes", + } diff --git a/src/frontend/client/src/components/permission/PermissionListTab.test.tsx b/src/frontend/client/src/components/permission/PermissionListTab.test.tsx index 7698b0a398..467364fa72 100644 --- a/src/frontend/client/src/components/permission/PermissionListTab.test.tsx +++ b/src/frontend/client/src/components/permission/PermissionListTab.test.tsx @@ -174,6 +174,10 @@ describe("F048 Client PermissionListTab", () => { const protectedRow = await screen.findByTestId("permission-assignee-3"); expect(protectedRow).toHaveAttribute("data-editable", "false"); + expect(protectedRow).toHaveTextContent( + "f048_permission.roster.parent_folder", + ); + expect(protectedRow).not.toHaveTextContent("folder:parent-1"); expect( screen.getByLabelText("f048_permission.roster.protected"), ).toBeInTheDocument(); diff --git a/src/frontend/client/src/components/permission/PermissionListTab.tsx b/src/frontend/client/src/components/permission/PermissionListTab.tsx index dc7b578142..f052023408 100644 --- a/src/frontend/client/src/components/permission/PermissionListTab.tsx +++ b/src/frontend/client/src/components/permission/PermissionListTab.tsx @@ -76,6 +76,26 @@ function getAvatarLabel(assignee: PermissionGrantAssignee): string { return (name.charAt(0) || "U").toUpperCase(); } +function getInheritedSourceLabel( + assignee: PermissionGrantAssignee, + localize: (key: string) => string, +): string { + const resolvedName = assignee.inherited_from_name?.trim(); + if (resolvedName) return resolvedName; + + const resourceType = assignee.inherited_from?.split(":", 1)[0]; + if (resourceType === "folder") { + return localize("f048_permission.roster.parent_folder"); + } + if ( + resourceType === "knowledge_space" || + resourceType === "knowledge_library" + ) { + return localize("f048_permission.roster.parent_knowledge_space"); + } + return localize("f048_permission.roster.parent_resource"); +} + interface RosterRowProps { assignee: PermissionGrantAssignee; context: ResourcePermissionContext; @@ -135,7 +155,7 @@ function RosterRow({ {assignee.inherited_from && ( · {localize("f048_permission.roster.inherited_from")}: {" "} - {assignee.inherited_from_name || assignee.inherited_from} + {getInheritedSourceLabel(assignee, localize)} )} diff --git a/src/frontend/client/src/locales/en/translation.json b/src/frontend/client/src/locales/en/translation.json index e00d848b09..0b3edd17c5 100644 --- a/src/frontend/client/src/locales/en/translation.json +++ b/src/frontend/client/src/locales/en/translation.json @@ -2122,7 +2122,10 @@ "summary_only": "You can only view your own permission summary.", "protected": "Protected", "read_only": "Read-only", - "inherited_from": "Inherited from" + "inherited_from": "Inherited from", + "parent_folder": "Parent folder", + "parent_knowledge_space": "Parent knowledge space", + "parent_resource": "Parent resource" }, "scope": { "local": "Local", diff --git a/src/frontend/client/src/locales/ja/translation.json b/src/frontend/client/src/locales/ja/translation.json index c32f67ed63..f471664c8d 100644 --- a/src/frontend/client/src/locales/ja/translation.json +++ b/src/frontend/client/src/locales/ja/translation.json @@ -2045,7 +2045,10 @@ "summary_only": "自分の権限概要のみ表示できます。", "protected": "保護対象", "read_only": "読み取り専用", - "inherited_from": "継承元" + "inherited_from": "継承元", + "parent_folder": "上位フォルダー", + "parent_knowledge_space": "上位ナレッジスペース", + "parent_resource": "上位リソース" }, "scope": { "local": "ローカル", diff --git a/src/frontend/client/src/locales/zh-Hans/translation.json b/src/frontend/client/src/locales/zh-Hans/translation.json index f2feac686d..2aa9165314 100644 --- a/src/frontend/client/src/locales/zh-Hans/translation.json +++ b/src/frontend/client/src/locales/zh-Hans/translation.json @@ -2051,7 +2051,10 @@ "summary_only": "你只能查看自己的权限摘要", "protected": "受保护", "read_only": "只读", - "inherited_from": "继承自" + "inherited_from": "继承自", + "parent_folder": "上级文件夹", + "parent_knowledge_space": "上级知识空间", + "parent_resource": "上级资源" }, "scope": { "local": "本级", diff --git a/src/frontend/platform/public/locales/en-US/permission.json b/src/frontend/platform/public/locales/en-US/permission.json index be283ab3a2..92781cc561 100644 --- a/src/frontend/platform/public/locales/en-US/permission.json +++ b/src/frontend/platform/public/locales/en-US/permission.json @@ -191,7 +191,10 @@ "summaryOnly": "You can only view your own permission summary.", "protected": "Protected", "readOnly": "Read-only", - "inheritedFrom": "Inherited from" + "inheritedFrom": "Inherited from", + "parentFolder": "Parent folder", + "parentKnowledgeSpace": "Parent knowledge space", + "parentResource": "Parent resource" }, "scope": { "local": "Local", diff --git a/src/frontend/platform/public/locales/ja/permission.json b/src/frontend/platform/public/locales/ja/permission.json index fec23129ec..b2bfaf9cfc 100644 --- a/src/frontend/platform/public/locales/ja/permission.json +++ b/src/frontend/platform/public/locales/ja/permission.json @@ -191,7 +191,10 @@ "summaryOnly": "自分の権限概要のみ表示できます。", "protected": "保護対象", "readOnly": "読み取り専用", - "inheritedFrom": "継承元" + "inheritedFrom": "継承元", + "parentFolder": "上位フォルダー", + "parentKnowledgeSpace": "上位ナレッジスペース", + "parentResource": "上位リソース" }, "scope": { "local": "ローカル", diff --git a/src/frontend/platform/public/locales/zh-Hans/permission.json b/src/frontend/platform/public/locales/zh-Hans/permission.json index e5439d3701..2a18292ecf 100644 --- a/src/frontend/platform/public/locales/zh-Hans/permission.json +++ b/src/frontend/platform/public/locales/zh-Hans/permission.json @@ -191,7 +191,10 @@ "summaryOnly": "你只能查看自己的权限摘要", "protected": "受保护", "readOnly": "只读", - "inheritedFrom": "继承自" + "inheritedFrom": "继承自", + "parentFolder": "上级文件夹", + "parentKnowledgeSpace": "上级知识空间", + "parentResource": "上级资源" }, "scope": { "local": "本级", 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 489fc101e3..bbf20f7dae 100644 --- a/src/frontend/platform/src/components/bs-comp/permission/PermissionListTab.tsx +++ b/src/frontend/platform/src/components/bs-comp/permission/PermissionListTab.tsx @@ -72,6 +72,24 @@ function getAvatarLabel(assignee: PermissionGrantAssignee): string { return (name.charAt(0) || "U").toUpperCase() } +function getInheritedSourceLabel( + assignee: PermissionGrantAssignee, + translate: (key: string) => string, +): string { + const resolvedName = assignee.inherited_from_name?.trim() + if (resolvedName) return resolvedName + + const resourceType = assignee.inherited_from?.split(":", 1)[0] + if (resourceType === "folder") return translate("roster.parentFolder") + if ( + resourceType === "knowledge_space" || + resourceType === "knowledge_library" + ) { + return translate("roster.parentKnowledgeSpace") + } + return translate("roster.parentResource") +} + interface RosterRowProps { assignee: PermissionGrantAssignee context: ResourcePermissionContext @@ -127,7 +145,7 @@ function RosterRow({ {assignee.inherited_from && ( · {t("roster.inheritedFrom")}:{" "} - {assignee.inherited_from_name || assignee.inherited_from} + {getInheritedSourceLabel(assignee, t)} )} diff --git a/src/frontend/platform/src/test/f048PermissionRoster.test.tsx b/src/frontend/platform/src/test/f048PermissionRoster.test.tsx index 8265957adc..8da042e2bb 100644 --- a/src/frontend/platform/src/test/f048PermissionRoster.test.tsx +++ b/src/frontend/platform/src/test/f048PermissionRoster.test.tsx @@ -127,7 +127,7 @@ describe("F048 PermissionListTab", () => { { ...departmentAssignee, scope: "INHERITED", - inherited_from: "knowledge_space:space-1", + inherited_from: "folder:94661", editable: false, }, ], @@ -146,10 +146,40 @@ describe("F048 PermissionListTab", () => { const row = await screen.findByTestId("permission-assignee-102") expect(row).toHaveTextContent("scope.inherited") - expect(row).toHaveTextContent("knowledge_space:space-1") + expect(row).toHaveTextContent("roster.parentFolder") + expect(row).not.toHaveTextContent("folder:94661") expect(row).toHaveAttribute("data-editable", "false") }) + it("shows the resolved inherited resource name when available", async () => { + vi.mocked(getResourcePermissionGrantsApi).mockResolvedValue({ + data: [ + { + ...departmentAssignee, + scope: "INHERITED", + inherited_from: "folder:94661", + inherited_from_name: "Release Notes", + editable: false, + }, + ], + page_size: 50, + has_more: false, + next_cursor: null, + }) + + render( + , + ) + + const row = await screen.findByTestId("permission-assignee-102") + expect(row).toHaveTextContent("Release Notes") + expect(row).not.toHaveTextContent("folder:94661") + }) + it("requests only the current-user summary without roster permission", async () => { render( Date: Thu, 20 Aug 2026 18:20:40 +0800 Subject: [PATCH 4/4] fix(permission): project creator visible on resource creation --- .../permission/application/control_state.py | 32 ++++++++++---- .../domain/services/owner_service.py | 43 ++++++++++++++++--- .../permission/test_f048_owner_projection.py | 16 +++++-- 3 files changed, 72 insertions(+), 19 deletions(-) diff --git a/src/backend/bisheng/permission/application/control_state.py b/src/backend/bisheng/permission/application/control_state.py index 504f8448d2..37f8f3edfa 100644 --- a/src/backend/bisheng/permission/application/control_state.py +++ b/src/backend/bisheng/permission/application/control_state.py @@ -4,7 +4,6 @@ import secrets from dataclasses import dataclass -from typing import Any from sqlalchemy import update from sqlmodel import col, select @@ -408,9 +407,7 @@ async def load_source_page( # The parent's creator carries no authority here — this resource # has its own protected creator row, and the inherited copy only # showed up as a second, identical entry that cannot be acted on. - inherited_rows = [ - (row, model_key) for row, model_key in inherited_rows if row.source_type != "CREATOR" - ] + inherited_rows = [(row, model_key) for row, model_key in inherited_rows if row.source_type != "CREATOR"] combined = sorted( ( *((row, model_key, "LOCAL") for row, model_key in local_rows), @@ -543,6 +540,7 @@ async def prepare_owner( context: OwnerProjectionContext, grant: GrantSnapshot | None, source: GrantSourceRecord | None, + visibility: VisibilityProjectionCompilation | None, *, operation_id: int, ) -> None: @@ -581,19 +579,26 @@ async def prepare_owner( source=projection_source, state="PENDING", ) + if visibility is not None: + await self._prepare_visible_sources( + session, + tenant_id=context.target.tenant_id, + visibility=visibility, + operation_id=operation_id, + ) async def finalize_owner( self, context: OwnerProjectionContext, grant: GrantSnapshot | None, - outcome: Any, + visibility: VisibilityProjectionCompilation | None, + outcome: ProjectionOutcome, ) -> None: - del outcome projection_grants = self._owner_projection_grants( context, grant, ) - if not projection_grants: + if not projection_grants and visibility is None: return async with get_async_db_session() as session: async with session.begin(): @@ -623,6 +628,13 @@ async def finalize_owner( ) .values(state="ACTIVE") ) + if visibility is not None: + await self._finalize_visible_sources( + session, + tenant_id=context.target.tenant_id, + visibility=visibility, + operation_id=outcome.operation_id, + ) async def mark_owner_compensation( self, @@ -1361,6 +1373,7 @@ async def prepare( context, grant, source, + visibility, *, operation_id, ) -> None: @@ -1368,11 +1381,12 @@ async def prepare( context, grant, source, + visibility, operation_id=operation_id, ) - async def finalize(self, context, grant, outcome) -> None: - await self._state.finalize_owner(context, grant, outcome) + async def finalize(self, context, grant, visibility, outcome) -> None: + await self._state.finalize_owner(context, grant, visibility, outcome) async def mark_compensation_required(self, context, error) -> None: await self._state.mark_owner_compensation(context, error) diff --git a/src/backend/bisheng/permission/domain/services/owner_service.py b/src/backend/bisheng/permission/domain/services/owner_service.py index eb9ff70aa4..c4efa47ba9 100644 --- a/src/backend/bisheng/permission/domain/services/owner_service.py +++ b/src/backend/bisheng/permission/domain/services/owner_service.py @@ -7,7 +7,7 @@ from __future__ import annotations import logging -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import Protocol from bisheng.common.errcode.permission import PermissionInvalidResourceError @@ -24,10 +24,15 @@ from bisheng.permission.domain.services.projection_plan import ( ProjectionOutcome, ProjectionTupleDelta, + merge_projection_deltas, ) from bisheng.permission.domain.services.resource_lifecycle_policy import ( build_create_plan, ) +from bisheng.permission.domain.services.visibility_projection_service import ( + VisibilityProjectionCompilation, + VisibilityProjectionCompiler, +) logger = logging.getLogger(__name__) @@ -78,6 +83,7 @@ async def prepare( context: OwnerProjectionContext, grant: GrantSnapshot | None, source: GrantSourceRecord | None, + visibility: VisibilityProjectionCompilation | None, *, operation_id: int, ) -> None: ... @@ -86,7 +92,8 @@ async def finalize( self, context: OwnerProjectionContext, grant: GrantSnapshot | None, - outcome: object, + visibility: VisibilityProjectionCompilation | None, + outcome: ProjectionOutcome, ) -> None: ... async def mark_compensation_required( @@ -105,18 +112,22 @@ def __init__( source_service: GrantSourceService, projection: OwnerProjectionPort, state: OwnerProjectionStatePort, + visibility_compiler: VisibilityProjectionCompiler | None = None, ) -> None: self._sources = source_service self._projection = projection self._state = state + self._visibility = visibility_compiler or VisibilityProjectionCompiler() async def project_created( self, context: OwnerProjectionContext, ) -> OwnerProjectionResult: self._validate_common(context) + self._validate_copy(context) grant: GrantSnapshot | None source: GrantSourceRecord | None + visibility: VisibilityProjectionCompilation | None protected_deltas: tuple[ProjectionTupleDelta, ...] if context.system_owned: self._validate_system_owned(context) @@ -137,12 +148,18 @@ async def project_created( ) for index, relation in enumerate(dict.fromkeys(marker_relations)) ) + visibility = None else: grant, source, protected_deltas = self._protected_owner(context) - - self._validate_copy(context) - all_deltas = tuple( - replace(delta, sequence=index) for index, delta in enumerate((*context.copy_deltas, *protected_deltas)) + visibility = self._visibility.compile( + tenant_id=context.target.tenant_id, + grants=self._projection_grants(context, grant), + existing_sources=(), + ) + all_deltas = merge_projection_deltas( + context.copy_deltas, + protected_deltas, + visibility.deltas if visibility is not None else (), ) plan = build_create_plan( context.target, @@ -161,6 +178,7 @@ async def project_created( context, grant, source, + visibility, operation_id=int(operation.id), ) except Exception as exc: @@ -171,7 +189,7 @@ async def project_created( except Exception as exc: await self._state.mark_compensation_required(context, exc) raise - await self._state.finalize(context, grant, outcome) + await self._state.finalize(context, grant, visibility, outcome) return OwnerProjectionResult( grant=grant, source=source, @@ -208,6 +226,17 @@ def _protected_owner( mutation = self._sources.add_source(grant, source) return mutation.grant, source, mutation.deltas + @staticmethod + def _projection_grants( + context: OwnerProjectionContext, + owner_grant: GrantSnapshot, + ) -> tuple[GrantSnapshot, ...]: + if not context.copy_grants: + return (owner_grant,) + by_model = {grant.model.model_key: grant for grant in context.copy_grants} + by_model[owner_grant.model.model_key] = owner_grant + return tuple(by_model[key] for key in sorted(by_model) if by_model[key].active and by_model[key].sources) + @staticmethod def _validate_common(context: OwnerProjectionContext) -> None: if ( diff --git a/src/backend/test/permission/test_f048_owner_projection.py b/src/backend/test/permission/test_f048_owner_projection.py index e24a8065f4..de53036f22 100644 --- a/src/backend/test/permission/test_f048_owner_projection.py +++ b/src/backend/test/permission/test_f048_owner_projection.py @@ -61,13 +61,14 @@ async def prepare( context, grant, source, + visibility, *, operation_id: int, ): - self.prepared.append((context, grant, source, operation_id)) + self.prepared.append((context, grant, source, visibility, operation_id)) - async def finalize(self, context, grant, outcome): - self.finalized.append((context, grant, outcome)) + async def finalize(self, context, grant, visibility, outcome): + self.finalized.append((context, grant, visibility, outcome)) async def mark_compensation_required(self, context, error): self.compensations.append((context, error)) @@ -170,6 +171,13 @@ async def test_creation_adds_one_protected_creator_without_replacing_owners() -> assert {source.projected_subject for source in result.grant.sources} == {"user:7", "user:8"} assert len(state.prepared) == len(state.finalized) == 1 assert any(delta.relation == "protected_assignee" for delta in projection.plans[0].deltas) + assert {(delta.user, delta.relation) for delta in projection.plans[0].deltas} >= { + ("user:7", "visible"), + ("user:8", "visible"), + } + visibility = state.prepared[0][3] + assert visibility is not None + assert {source.projected_subject for source in visibility.active_sources} == {"user:7", "user:8"} @pytest.mark.asyncio @@ -248,6 +256,8 @@ async def test_custom_copy_projects_ordinary_sources_and_new_creator_atomically( assert {(row.user, row.relation) for row in plan.deltas} >= { ("user:8", "ordinary_assignee"), ("user:7", "protected_assignee"), + ("user:8", "visible"), + ("user:7", "visible"), } assert result.grant is not None assert result.grant.sources[0].protected is True