From 023f8f6dc799733de8a2ad9062e223fefafb2540 Mon Sep 17 00:00:00 2001 From: GuoQing Zhang Date: Wed, 19 Aug 2026 11:58:10 +0800 Subject: [PATCH 1/2] fix(permission): my-permissions reports full effective actions for admins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A super admin / tenant admin is authorized on identity and holds no grant rows, so get_my_permissions built its action list from an empty grant set and returned actions: [] — the resource showed as visible but with no edit/delete/ manage capability ('visible but powerless'). Report the full effective action set for the resource type when the actor is privileged. Adds a bulk effective_actions(resource_type) lookup (the batch sibling of is_action_effective) exposed through the runtime. Ordinary users stay on the grant-derived path and still require a real visible relation. --- .../permission/application/resource_api.py | 14 ++++ .../bisheng/permission/application/runtime.py | 5 ++ .../permission/application/sql_runtime.py | 31 ++++++++ .../services/permission_action_service.py | 15 ++++ .../test/permission/test_f048_resource_api.py | 73 +++++++++++++++++++ 5 files changed, 138 insertions(+) diff --git a/src/backend/bisheng/permission/application/resource_api.py b/src/backend/bisheng/permission/application/resource_api.py index 4c1f9a7eef..b277c6d3e6 100644 --- a/src/backend/bisheng/permission/application/resource_api.py +++ b/src/backend/bisheng/permission/application/resource_api.py @@ -267,6 +267,20 @@ async def get_my_permissions( "visible", ) await self._require_visible(actor, target) + if self._privileged(actor, target): + # A super admin / tenant admin is authorized on identity and holds + # no grant rows, so the grant-derived explanation would report an + # empty action set — "visible but powerless", which is exactly what + # made the client show them as having no permissions. Report the + # full effective action set for the resource type instead. + mode = await self._runtime.current_mode(target) + actions = await self._runtime.effective_actions(resource_type) + return { + "mode": mode.mode, + "actions": list(actions), + "sources": [], + "roster_complete": False, + } explanation = await self._explanation( actor, target, diff --git a/src/backend/bisheng/permission/application/runtime.py b/src/backend/bisheng/permission/application/runtime.py index 969109d570..9f3b6ceb03 100644 --- a/src/backend/bisheng/permission/application/runtime.py +++ b/src/backend/bisheng/permission/application/runtime.py @@ -155,6 +155,11 @@ async def batch_check_actions( async def current_catalog(self) -> RuntimeCatalogSnapshot: return await self._runtime_catalog() + async def effective_actions(self, resource_type: str) -> tuple[str, ...]: + """All action codes effective for a resource type in the CURRENT catalog.""" + + return await self._decision.effective_actions(resource_type) + async def prospective_owner_grantable_models( self, ) -> tuple[RuntimeCatalogSnapshot, tuple[RuntimeModelSnapshot, ...]]: diff --git a/src/backend/bisheng/permission/application/sql_runtime.py b/src/backend/bisheng/permission/application/sql_runtime.py index 1c9b6aa2bb..c46a823f11 100644 --- a/src/backend/bisheng/permission/application/sql_runtime.py +++ b/src/backend/bisheng/permission/application/sql_runtime.py @@ -141,6 +141,37 @@ async def is_action_effective( ) return (await session.execute(statement)).scalar_one_or_none() is not None + async def effective_actions(self, resource_type: str) -> tuple[str, ...]: + """Every active, assigned action code effective for one resource type. + + The bulk sibling of ``is_action_effective``: one query returns the whole + effective action set (in catalog display order) so a privileged actor, + who holds no grant rows, can be reported as able to do all of them. + """ + + async with get_async_db_session() as session: + statement = ( + select(PermissionAction.code) + .join( + PermissionCatalogRelease, + PermissionCatalogRelease.id == PermissionAction.catalog_release_id, + ) + .join( + PermissionActionResourceScope, + PermissionActionResourceScope.action_id == PermissionAction.id, + ) + .where( + PermissionCatalogRelease.status == "CURRENT", + PermissionCatalogRelease.write_fenced == 0, + PermissionAction.active == 1, + PermissionAction.level.is_not(None), + PermissionActionResourceScope.resource_type == resource_type, + ) + .order_by(PermissionAction.sort_order, PermissionAction.code) + ) + rows = (await session.execute(statement)).scalars().all() + return tuple(dict.fromkeys(rows)) + class SqlPermissionScopeFence: """Trust only a CURRENT permission-owned mirror of a verified target.""" diff --git a/src/backend/bisheng/permission/domain/services/permission_action_service.py b/src/backend/bisheng/permission/domain/services/permission_action_service.py index a0b36a5828..5edd62b6d4 100644 --- a/src/backend/bisheng/permission/domain/services/permission_action_service.py +++ b/src/backend/bisheng/permission/domain/services/permission_action_service.py @@ -47,6 +47,11 @@ async def is_action_effective( action: str, ) -> bool: ... + async def effective_actions( + self, + resource_type: str, + ) -> tuple[str, ...]: ... + class PermissionScopeFencePort(Protocol): async def ensure_readable( @@ -476,6 +481,16 @@ async def list_action_objects( raise PermissionProjectionFailedError(msg="OpenFGA ListObjects returned an unexpected object type") return tuple(dict.fromkeys(value[len(prefix) :] for value in objects)) + async def effective_actions(self, resource_type: str) -> tuple[str, ...]: + """All action codes effective for a resource type in the CURRENT catalog. + + Used to report the full capability of a privileged actor, who is + authorized on identity and therefore holds no grant rows to explain. + """ + + await self._catalog.ensure_runtime_ready() + return await self._catalog.effective_actions(resource_type) + @staticmethod def _normalize_action(action: str) -> str: normalized = action.strip() diff --git a/src/backend/test/permission/test_f048_resource_api.py b/src/backend/test/permission/test_f048_resource_api.py index 990193b101..8e5b922807 100644 --- a/src/backend/test/permission/test_f048_resource_api.py +++ b/src/backend/test/permission/test_f048_resource_api.py @@ -124,6 +124,9 @@ async def canonical_source(self, *, tenant_id, source_id, **kwargs): async def display_names(self, subjects): return {("user", "8"): "Member 8"} + async def actor_projected_subjects(self, actor): + return frozenset({f"user:{actor.user_id}"}) + @pytest.mark.asyncio async def test_super_admin_subject_validation_uses_target_tenant() -> None: @@ -195,6 +198,30 @@ async def current_mode(self, target): del target return SimpleNamespace(mode="CUSTOM", projection_state="READY") + async def effective_actions(self, resource_type): + del resource_type + # 'visible' is a base relation, not a registered action, so it never + # appears in the effective action set. + return ("use", "edit", "delete", "manage_permission") + + +class _ExplainRuntime(_ContextRuntime): + """Visible via FGA, but the grant-derived explanation is empty. + + Models an ordinary user who can see the resource yet holds no grant rows — + the path that must stay grant-derived (and NOT be handed the full set). + """ + + async def check_action(self, actor, target, action): + if action == "visible": + self.visible_checks += 1 + return True + return True + + async def explain_permissions(self, **kwargs): + del kwargs + return SimpleNamespace(mode="CUSTOM", action_codes=(), sources=()) + @pytest.mark.asyncio async def test_super_admin_reads_context_without_a_visible_tuple() -> None: @@ -264,6 +291,52 @@ async def test_ordinary_user_context_still_requires_a_visible_tuple() -> None: assert runtime.visible_checks == 1 +@pytest.mark.asyncio +async def test_super_admin_my_permissions_returns_full_effective_actions() -> None: + runtime = _ContextRuntime() + api = F048ResourcePermissionApi( + resources=_Resources(), + runtime=runtime, + subjects=_Subjects(), + ) + actor = PermissionActor(user_id=7, current_tenant_id=5, super_admin=True) + + result = await api.get_my_permissions( + resource_type="workflow", + resource_id="wf-1", + actor=actor, + ) + + # No grant rows exist for a super admin, so the grant-derived explanation + # would be empty; the full effective action set is reported instead. + assert result["actions"] == ["use", "edit", "delete", "manage_permission"] + assert result["sources"] == [] + assert runtime.visible_checks == 0 + + +@pytest.mark.asyncio +async def test_ordinary_user_my_permissions_stays_grant_derived() -> None: + runtime = _ExplainRuntime() + api = F048ResourcePermissionApi( + resources=_Resources(), + runtime=runtime, + subjects=_Subjects(), + ) + # Same tenant as the resolved target (9) but no admin rights. + actor = PermissionActor(user_id=7, current_tenant_id=9) + + result = await api.get_my_permissions( + resource_type="workflow", + resource_id="wf-1", + actor=actor, + ) + + # Ordinary user: went through the real visibility check and stayed on the + # grant-derived path (empty here), never handed the full effective set. + assert runtime.visible_checks == 1 + assert result["actions"] == [] + + @pytest.mark.asyncio async def test_roster_uses_bounded_sql_page_instead_of_full_explanation() -> None: runtime = _Runtime() From f26d6217bbfbf178a9a50e254431c7b6a49c0ce4 Mon Sep 17 00:00:00 2001 From: dolphin Date: Wed, 19 Aug 2026 12:11:55 +0800 Subject: [PATCH 2/2] fix(client): stop using crypto.randomUUID over plain HTTP crypto.randomUUID only exists in a secure context (HTTPS/localhost), so it throws TypeError on any HTTP deployment. Switch the channel- and knowledge- space-settings idempotency keys to the existing generateUUID() utility already used elsewhere in the app. --- .../Subscription/ChannelSettings/useChannelSettingsForm.ts | 7 ++++--- .../SpaceSettings/useKnowledgeSpaceSettingsForm.ts | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/frontend/client/src/pages/Subscription/ChannelSettings/useChannelSettingsForm.ts b/src/frontend/client/src/pages/Subscription/ChannelSettings/useChannelSettingsForm.ts index 546961ed5b..51ab8b1d4c 100644 --- a/src/frontend/client/src/pages/Subscription/ChannelSettings/useChannelSettingsForm.ts +++ b/src/frontend/client/src/pages/Subscription/ChannelSettings/useChannelSettingsForm.ts @@ -1,6 +1,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { generateUUID } from "~/utils"; import { createManagerChannelApi, getChannelDetailApi, @@ -96,7 +97,7 @@ export function useChannelSettingsForm(channelId?: string) { const [submitting, setSubmitting] = useState(false); const [authorizationRecovery, setAuthorizationRecovery] = useState(null); const [catalogReleaseId, setCatalogReleaseId] = useState(null); - const creationRequestId = useMemo(() => crypto.randomUUID(), []); + const creationRequestId = useMemo(() => generateUUID(32), []); const initBusinessFromChannel = business.initFromChannel; const setBusinessSources = business.setSources; const loadBusinessSourcesByIds = business.loadSourcesByIds; @@ -262,7 +263,7 @@ export function useChannelSettingsForm(channelId?: string) { ) { const latestContext = await getResourcePermissionContext("channel", channelId); await mutateResourceGrants("channel", channelId, { - idempotency_key: crypto.randomUUID(), + idempotency_key: generateUUID(32), expected_resource_version: latestContext.resource_version, expected_catalog_release_id: latestContext.catalog_release_id, changes: permissionDraft.diff.changes, @@ -292,7 +293,7 @@ export function useChannelSettingsForm(channelId?: string) { try { const context = await getResourcePermissionContext("channel", authorizationRecovery.channelId); await mutateResourceGrants("channel", authorizationRecovery.channelId, { - idempotency_key: crypto.randomUUID(), + idempotency_key: generateUUID(32), expected_resource_version: context.resource_version, expected_catalog_release_id: context.catalog_release_id, changes: permissionDraft.diff.changes, diff --git a/src/frontend/client/src/pages/knowledge/SpaceSettings/useKnowledgeSpaceSettingsForm.ts b/src/frontend/client/src/pages/knowledge/SpaceSettings/useKnowledgeSpaceSettingsForm.ts index 1529dac4c1..6d6c37a067 100644 --- a/src/frontend/client/src/pages/knowledge/SpaceSettings/useKnowledgeSpaceSettingsForm.ts +++ b/src/frontend/client/src/pages/knowledge/SpaceSettings/useKnowledgeSpaceSettingsForm.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import { generateUUID } from "~/utils"; import { createSpaceApi, getKnowledgeSpaceAutoTagVisibilityApi, @@ -104,7 +105,7 @@ export function useKnowledgeSpaceSettingsForm(spaceId?: string) { const [canManagePermissions, setCanManagePermissions] = useState(false); const [relationModels, setRelationModels] = useState([]); const [catalogReleaseId, setCatalogReleaseId] = useState(null); - const creationRequestIdRef = useRef(crypto.randomUUID()); + const creationRequestIdRef = useRef(generateUUID(32)); const [createdSpace, setCreatedSpace] = useState(null); const [permissionRetryStatus, setPermissionRetryStatus] = useState< "idle" | "retrying" | "success" | "failed" @@ -325,7 +326,7 @@ export function useKnowledgeSpaceSettingsForm(spaceId?: string) { ) { const latestContext = await getResourcePermissionContext("knowledge_space", spaceId); await mutateResourceGrants("knowledge_space", spaceId, { - idempotency_key: crypto.randomUUID(), + idempotency_key: generateUUID(32), expected_resource_version: latestContext.resource_version, expected_catalog_release_id: latestContext.catalog_release_id, changes: permissionDiff.changes, @@ -362,7 +363,7 @@ export function useKnowledgeSpaceSettingsForm(spaceId?: string) { try { const context = await getResourcePermissionContext("knowledge_space", createdSpace.id); await mutateResourceGrants("knowledge_space", createdSpace.id, { - idempotency_key: crypto.randomUUID(), + idempotency_key: generateUUID(32), expected_resource_version: context.resource_version, expected_catalog_release_id: context.catalog_release_id, changes: permissionDraft.diff.changes,