Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/backend/bisheng/workstation/api/endpoints/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ async def get_recommended_apps(login_user=LoginUserDep):
even if an app later went offline.
- Regular users (chat landing): filter to online apps the user can access.
"""
config = await WorkStationService.aget_config()
config = await WorkStationService.aget_config(login_user=login_user)
if not config or not config.recommendedApps:
return resp_200(data=[])

Expand Down
20 changes: 10 additions & 10 deletions src/backend/bisheng/workstation/api/endpoints/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@

@router.get("/config", summary="Get workbench configuration", response_model=UnifiedResponseModel)
async def get_config(request: Request, login_user=LoginUserDep):
ret = await WorkStationService.get_daily_chat_config()
linsight_config = await WorkStationService.get_linsight_config()
ret = await WorkStationService.get_daily_chat_config(login_user=login_user)
linsight_config = await WorkStationService.get_linsight_config(login_user=login_user)
# `enable_etl4lm` historically gated the frontend on `etl4lm.url` alone, but the
# parse pipeline now supports mineru / paddle_ocr as alternative providers that
# also handle images. Use the unified image-parsing capability flag so the flag
Expand Down Expand Up @@ -60,18 +60,16 @@ async def get_config(request: Request, login_user=LoginUserDep):
ret["subscription"] = {"assistant_name": (sub_assistant_cfg.assistant_name or "") if sub_assistant_cfg else ""}
# Sidebar entry names for the knowledge-space / subscription modules; the home and
# app-center ones ride along in the daily config dump above. Empty => client i18n default.
ret["knowledge_space"]["menu_display_name"] = (
(ks_assistant_cfg.menu_display_name or "") if ks_assistant_cfg else ""
)
ret["subscription"]["menu_display_name"] = (
(sub_assistant_cfg.menu_display_name or "") if sub_assistant_cfg else ""
)
ret["knowledge_space"]["menu_display_name"] = (ks_assistant_cfg.menu_display_name or "") if ks_assistant_cfg else ""
ret["subscription"]["menu_display_name"] = (sub_assistant_cfg.menu_display_name or "") if sub_assistant_cfg else ""
return resp_200(data=ret)


@router.get("/config/daily", summary="Get daily workbench configuration", response_model=UnifiedResponseModel)
async def get_daily_config(request: Request, login_user=LoginUserDep):
ret, inherited, source_tenant_id, has_override = await WorkStationService.get_daily_chat_config_with_meta()
ret, inherited, source_tenant_id, has_override = await WorkStationService.get_daily_chat_config_with_meta(
login_user=login_user
)
return resp_200(
data={
"data": ret.model_dump(exclude_unset=True) if ret else None,
Expand All @@ -94,7 +92,9 @@ async def update_daily_config(

@router.get("/config/linsight", summary="Get linsight configuration", response_model=UnifiedResponseModel)
async def get_linsight_config(request: Request, login_user=LoginUserDep):
ret, inherited, source_tenant_id, has_override = await WorkStationService.get_linsight_config_with_meta()
ret, inherited, source_tenant_id, has_override = await WorkStationService.get_linsight_config_with_meta(
login_user=login_user
)
return resp_200(
data={
"data": ret.model_dump(exclude_unset=True) if ret else None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ async def get_file_content(filepath_local: str, file_name: str, invoke_user_id:

async def initialize_chat(data: APIChatCompletion, login_user: UserPayload):
"""Initialize chat session, message, and llm."""
ws_config = await WorkStationService.aget_config()
ws_config = await WorkStationService.aget_config(login_user=login_user)
model_info = next((model for model in ws_config.models if model.id == data.model), None)
if not model_info:
raise ValueError(f"Model with id '{data.model}' not found.")
Expand Down Expand Up @@ -1169,7 +1169,7 @@ async def _agent_initialize_chat(data: APIChatCompletion, login_user: UserPayloa
- `extra` is '{}' (no parentMessageId — new data is linear, no tree)
- `overrideParentMessageId` is ignored (regenerate UI removed)
"""
ws_config = await WorkStationService.aget_config()
ws_config = await WorkStationService.aget_config(login_user=login_user)
model_info = next((m for m in ws_config.models if m.id == data.model), None)
if not model_info:
raise ValueError(f"Model with id '{data.model}' not found.")
Expand Down
105 changes: 92 additions & 13 deletions src/backend/bisheng/workstation/domain/services/workstation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from bisheng.knowledge.domain.services.knowledge_service import KnowledgeService
from bisheng.llm.domain.schemas import WorkbenchModelConfig
from bisheng.llm.domain.services import LLMService
from bisheng.permission.domain.services.tool_permission_service import ToolPermissionService
from bisheng.tool.domain.const import ToolPresetType
from bisheng.tool.domain.langchain.knowledge import KnowledgeRetrieverTool
from bisheng.tool.domain.models.gpts_tools import GptsTools, GptsToolsDao, GptsToolsType
Expand Down Expand Up @@ -404,11 +405,52 @@ async def _ahydrate_tools_from_source_tenant(
)
return hydrated

@classmethod
async def _afilter_tools_by_view_permission(
cls,
grouped: list[dict],
login_user: UserPayload | None,
) -> list[dict]:
"""Drop tool groups the requester cannot see.

End users without ``view_tool`` on a configured tool must not see it in
the chat toolbar / agent tool selector. We filter at the parent
(tool_type) id — every child of that group shares the same access
because permissions are bound to the parent type in OpenFGA.

Admins (super / tenant / child) bypass the filter so the config
page echoes every configured tool. Callers without an injected
user (legacy code paths, tests that stub the method) keep the
pre-fix behaviour — no filter — so we do not regress the
pre-permission-visible config surface.
"""
if not grouped or login_user is None or login_user.is_admin():
return grouped

candidate_ids = [int(group["id"]) for group in grouped if group.get("id") is not None]
if not candidate_ids:
return grouped
try:
allowed = await ToolPermissionService.filter_tool_ids_by_permission_async(
login_user,
candidate_ids,
"view_tool",
)
except Exception:
# Fail closed: if the permission probe errors, hide every tool
# rather than leak. Logged once; the chat toolbar then renders
# empty (existing UX for an empty config).
logger.exception("workstation: tool permission filter failed; hiding all configured tools")
return []
allowed_set = {str(a) for a in allowed}
return [group for group in grouped if group.get("id") is not None and str(int(group["id"])) in allowed_set]

@classmethod
async def _aproject_tools_for_current_tenant(
cls,
tools: list | None,
source_tenant_id: int = DEFAULT_TENANT_ID,
login_user: UserPayload | None = None,
) -> list[dict]:
if not tools:
return []
Expand Down Expand Up @@ -479,7 +521,11 @@ async def _load_rows() -> tuple[list[GptsTools], list[GptsToolsType]]:
for group in grouped:
if group["id"] in default_checked_types:
group["default_checked"] = True
return grouped
# Apply the per-user view_tool filter last so a user who lacks
# permission on a configured tool does not see it in the chat
# toolbar (IKABQ0: "user without API/MCP tool permission still sees
# the tool in the workspace").
return await cls._afilter_tools_by_view_permission(grouped, login_user)

@classmethod
async def _afilter_org_kbs_for_current_tenant(cls, org_kbs: list | None) -> list[dict]:
Expand Down Expand Up @@ -516,11 +562,14 @@ async def _aproject_daily_config_for_current_tenant(
cls,
config: WorkstationConfig | None,
source_tenant_id: int = DEFAULT_TENANT_ID,
login_user: UserPayload | None = None,
) -> WorkstationConfig | None:
if config is None:
return None
updates = {
"tools": await cls._aproject_tools_for_current_tenant(config.tools, source_tenant_id),
"tools": await cls._aproject_tools_for_current_tenant(
config.tools, source_tenant_id, login_user=login_user
),
"orgKbs": await cls._afilter_org_kbs_for_current_tenant(config.orgKbs),
"recommendedApps": await cls._afilter_recommended_apps_for_current_tenant(config.recommendedApps),
}
Expand All @@ -531,10 +580,11 @@ async def _aproject_linsight_config_for_current_tenant(
cls,
config: LinsightConfig | None,
source_tenant_id: int = DEFAULT_TENANT_ID,
login_user: UserPayload | None = None,
) -> LinsightConfig | None:
if config is None:
return None
tools = await cls._aproject_tools_for_current_tenant(config.tools, source_tenant_id)
tools = await cls._aproject_tools_for_current_tenant(config.tools, source_tenant_id, login_user=login_user)
return config.model_copy(update={"tools": tools})

@classmethod
Expand Down Expand Up @@ -653,7 +703,10 @@ def get_config(cls) -> WorkstationConfig | None:
return cls._apply_workbench_models(ret, LLMService.get_workbench_llm_sync())

@classmethod
async def aget_config(cls) -> WorkstationConfig | None:
async def aget_config(
cls,
login_user: UserPayload | None = None,
) -> WorkstationConfig | None:
"""Get the default workstation configuration asynchronously."""
value, inherited, _, _ = await cls._aresolve_tenant_config(ConfigKeyEnum.WORKSTATION)
config = type("TenantConfigValue", (), {"value": value}) if value else None
Expand All @@ -663,11 +716,20 @@ async def aget_config(cls) -> WorkstationConfig | None:
if ret and not inherited:
ret.tools = cls.sync_tool_info(ret.tools)
if inherited:
ret = await cls._aproject_daily_config_for_current_tenant(ret, DEFAULT_TENANT_ID)
ret = await cls._aproject_daily_config_for_current_tenant(ret, DEFAULT_TENANT_ID, login_user=login_user)
elif login_user is not None:
# When the config is not inherited, the synced tool list still
# has to be filtered by the caller's permissions — otherwise
# the chat toolbar shows every tool the admin configured for
# this tenant, even ones the requester cannot see (IKABQ0).
ret.tools = await cls._afilter_tools_by_view_permission(ret.tools, login_user)
return cls._apply_workbench_models(ret, await LLMService.get_workbench_llm())

@classmethod
async def get_daily_chat_config(cls) -> WorkstationConfig | None:
async def get_daily_chat_config(
cls,
login_user: UserPayload | None = None,
) -> WorkstationConfig | None:
"""Get the default workstation configuration for daily chat."""
value, inherited, _, _ = await cls._aresolve_tenant_config(ConfigKeyEnum.WORKSTATION)
config = type("TenantConfigValue", (), {"value": value}) if value else None
Expand All @@ -677,7 +739,9 @@ async def get_daily_chat_config(cls) -> WorkstationConfig | None:
if ret and not inherited:
ret.tools = cls.sync_tool_info(ret.tools)
if inherited:
ret = await cls._aproject_daily_config_for_current_tenant(ret, DEFAULT_TENANT_ID)
ret = await cls._aproject_daily_config_for_current_tenant(ret, DEFAULT_TENANT_ID, login_user=login_user)
elif login_user is not None:
ret.tools = await cls._afilter_tools_by_view_permission(ret.tools, login_user)
return cls._apply_workbench_models(ret, await LLMService.get_workbench_llm())

@classmethod
Expand All @@ -691,7 +755,10 @@ async def update_daily_chat_config(cls, data: WorkstationConfig) -> WorkstationC
return await cls.get_daily_chat_config()

@classmethod
async def get_daily_chat_config_with_meta(cls) -> tuple[WorkstationConfig | None, bool, int, bool]:
async def get_daily_chat_config_with_meta(
cls,
login_user: UserPayload | None = None,
) -> tuple[WorkstationConfig | None, bool, int, bool]:
value, inherited, source_tenant_id, has_override = await cls._aresolve_tenant_config(ConfigKeyEnum.WORKSTATION)
config = type("TenantConfigValue", (), {"value": value}) if value else None
ret = cls.parse_config(config)
Expand All @@ -700,21 +767,28 @@ async def get_daily_chat_config_with_meta(cls) -> tuple[WorkstationConfig | None
if ret and not inherited:
ret.tools = cls.sync_tool_info(ret.tools)
if inherited:
ret = await cls._aproject_daily_config_for_current_tenant(ret, source_tenant_id)
ret = await cls._aproject_daily_config_for_current_tenant(ret, source_tenant_id, login_user=login_user)
elif login_user is not None:
ret.tools = await cls._afilter_tools_by_view_permission(ret.tools, login_user)
ret = cls._apply_workbench_models(ret, await LLMService.get_workbench_llm())
return ret, inherited, source_tenant_id, has_override

@classmethod
async def get_linsight_config(cls) -> LinsightConfig | None:
async def get_linsight_config(
cls,
login_user: UserPayload | None = None,
) -> LinsightConfig | None:
"""Get Linsight configuration."""
value, inherited, _, _ = await cls._aresolve_tenant_config(ConfigKeyEnum.WORKSTATION_LINSIGHT)
if not value:
return None
ret = LinsightConfig(**json.loads(value))
if inherited:
ret = await cls._aproject_linsight_config_for_current_tenant(ret, DEFAULT_TENANT_ID)
ret = await cls._aproject_linsight_config_for_current_tenant(ret, DEFAULT_TENANT_ID, login_user=login_user)
else:
ret.tools = cls.sync_tool_info(ret.tools)
if login_user is not None:
ret.tools = await cls._afilter_tools_by_view_permission(ret.tools, login_user)
return ret

@classmethod
Expand All @@ -727,17 +801,22 @@ async def update_linsight_config(cls, data: LinsightConfig) -> LinsightConfig:
return data

@classmethod
async def get_linsight_config_with_meta(cls) -> tuple[LinsightConfig | None, bool, int, bool]:
async def get_linsight_config_with_meta(
cls,
login_user: UserPayload | None = None,
) -> tuple[LinsightConfig | None, bool, int, bool]:
value, inherited, source_tenant_id, has_override = await cls._aresolve_tenant_config(
ConfigKeyEnum.WORKSTATION_LINSIGHT
)
if not value:
return None, inherited, source_tenant_id, has_override
ret = LinsightConfig(**json.loads(value))
if inherited:
ret = await cls._aproject_linsight_config_for_current_tenant(ret, source_tenant_id)
ret = await cls._aproject_linsight_config_for_current_tenant(ret, source_tenant_id, login_user=login_user)
else:
ret.tools = cls.sync_tool_info(ret.tools)
if login_user is not None:
ret.tools = await cls._afilter_tools_by_view_permission(ret.tools, login_user)
return ret, inherited, source_tenant_id, has_override

@classmethod
Expand Down
Loading