Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/backend/bisheng/permission/application/resource_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/backend/bisheng/permission/application/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...]]:
Expand Down
31 changes: 31 additions & 0 deletions src/backend/bisheng/permission/application/sql_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down
73 changes: 73 additions & 0 deletions src/backend/test/permission/test_f048_resource_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -96,7 +97,7 @@ export function useChannelSettingsForm(channelId?: string) {
const [submitting, setSubmitting] = useState(false);
const [authorizationRecovery, setAuthorizationRecovery] = useState<AuthorizationRecovery | null>(null);
const [catalogReleaseId, setCatalogReleaseId] = useState<number | null>(null);
const creationRequestId = useMemo(() => crypto.randomUUID(), []);
const creationRequestId = useMemo(() => generateUUID(32), []);
const initBusinessFromChannel = business.initFromChannel;
const setBusinessSources = business.setSources;
const loadBusinessSourcesByIds = business.loadSourcesByIds;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { generateUUID } from "~/utils";
import {
createSpaceApi,
getKnowledgeSpaceAutoTagVisibilityApi,
Expand Down Expand Up @@ -104,7 +105,7 @@ export function useKnowledgeSpaceSettingsForm(spaceId?: string) {
const [canManagePermissions, setCanManagePermissions] = useState(false);
const [relationModels, setRelationModels] = useState<GrantablePermissionModel[]>([]);
const [catalogReleaseId, setCatalogReleaseId] = useState<number | null>(null);
const creationRequestIdRef = useRef(crypto.randomUUID());
const creationRequestIdRef = useRef(generateUUID(32));
const [createdSpace, setCreatedSpace] = useState<KnowledgeSpace | null>(null);
const [permissionRetryStatus, setPermissionRetryStatus] = useState<
"idle" | "retrying" | "success" | "failed"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading