fix(web): block resource access for Agent apps (#41424)

This commit is contained in:
Wu Tianwei 2026-08-28 03:43:48 +00:00 committed by GitHub
parent 1971450ad8
commit f7d6cd1afe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 125 additions and 27 deletions

View File

@ -48,14 +48,18 @@ const mockUsePathname = mockNavigation.usePathname
const mockUseRouter = mockNavigation.useRouter
const mockFetchAppDetailDirect = vi.mocked(fetchAppDetailDirect)
const createAppDetail = (overrides: Partial<App> = {}) =>
type AppDetailFixture = App & {
bound_agent_id?: string | null
}
const createAppDetail = (overrides: Partial<AppDetailFixture> = {}) =>
({
id: 'app-1',
name: 'Demo App',
mode: AppModeEnum.WORKFLOW,
permission_keys: [AppACLPermission.ViewLayout, AppACLPermission.Monitor],
...overrides,
}) as App
}) as AppDetailFixture
const waitForAppContent = async () => {
await waitFor(() => {
@ -424,6 +428,52 @@ describe('AppDetailLayout', () => {
expect(useStore.getState().appDetail?.id).toBe('app-1')
})
it('should redirect Agent app access config URLs to the Agent configure page', async () => {
mockPathname = '/app/app-1/access-config'
mockFetchAppDetailDirect.mockResolvedValue(
createAppDetail({
mode: AppModeEnum.AGENT,
bound_agent_id: 'agent-1',
permission_keys: [AppACLPermission.AccessConfig],
}),
)
render(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
)
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/agents/agent-1/configure')
})
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
expect(useStore.getState().appDetail).toBeUndefined()
})
it('should keep Agent app access config content hidden while redirecting cached app data', async () => {
mockPathname = '/app/app-1/access-config'
useStore.getState().setAppDetail(
createAppDetail({
mode: AppModeEnum.AGENT,
bound_agent_id: 'agent-1',
permission_keys: [AppACLPermission.AccessConfig],
}),
)
render(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
)
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/agents/agent-1/configure')
})
expect(mockFetchAppDetailDirect).not.toHaveBeenCalled()
})
it('should redirect access config pages when RBAC is disabled', async () => {
mockIsRbacEnabled = false
mockPathname = '/app/app-1/access-config'

View File

@ -78,6 +78,8 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
appDetail?.id === appId ? appDetail : appDetailRes?.id === appId ? appDetailRes : null
const pageTitle = appDetailPageTitle(pathname, t)
const appName = routeAppDetail?.id === appId ? routeAppDetail.name : undefined
const shouldBlockAgentResourceAccess =
routeAppDetail?.mode === AppModeEnum.AGENT && pathname.endsWith('/access-config')
useDocumentTitle(`${pageTitle} · ${appName || t(($) => $['menus.appDetail'], { ns: 'common' })}`)
@ -145,7 +147,8 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
(isLogsPath && !appACLCapabilities.canAccessLogAndAnnotation) ||
(isAnnotationsPath && !appACLCapabilities.canAccessLogAndAnnotation) ||
(isOverviewPath && !appACLCapabilities.canMonitor) ||
(isAccessConfigPath && !appACLCapabilities.canAccessConfig) ||
(isAccessConfigPath &&
(routeAppDetail.mode === AppModeEnum.AGENT || !appACLCapabilities.canAccessConfig)) ||
(isDeployPath &&
(routeAppDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy))
) {
@ -194,27 +197,28 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
])
const isWorkflowPage = pathname.endsWith('/workflow')
const content = !appDetail ? (
<div className="flex min-w-0 grow items-center justify-center bg-background-body">
<Loading />
</div>
) : (
<div
className={cn(
'relative flex h-0 min-h-0 min-w-0 grow overflow-hidden',
!isWorkflowPage && 'pt-1 pr-1 pb-1',
)}
>
const content =
!appDetail || shouldBlockAgentResourceAccess ? (
<div className="flex min-w-0 grow items-center justify-center bg-background-body">
<Loading />
</div>
) : (
<div
className={cn(
'min-w-0 grow overflow-hidden bg-components-panel-bg',
!isWorkflowPage && 'rounded-lg shadow-xs shadow-shadow-shadow-3',
'relative flex h-0 min-h-0 min-w-0 grow overflow-hidden',
!isWorkflowPage && 'pt-1 pr-1 pb-1',
)}
>
{children}
<div
className={cn(
'min-w-0 grow overflow-hidden bg-components-panel-bg',
!isWorkflowPage && 'rounded-lg shadow-xs shadow-shadow-shadow-3',
)}
>
{children}
</div>
</div>
</div>
)
)
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background-body">

View File

@ -1,5 +1,6 @@
import { screen } from '@testing-library/react'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
import { AppACLPermission } from '@/utils/permission'
import AppDetailSection from '../app-detail-section'
@ -237,20 +238,37 @@ describe('AppDetailSection', () => {
expect(screen.queryByRole('link', { name: 'common.appMenus.deploy' })).not.toBeInTheDocument()
})
it('should render resource access navigation when app access config permission is granted', () => {
it.each([AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT])(
'should render resource access navigation for %s apps when app access config permission is granted',
(mode) => {
// Arrange
mockAppMode = mode
mockAppPermissionKeys = [AppACLPermission.AccessConfig]
// Act
render(<AppDetailSection />)
// Assert
expect(
screen.getByRole('link', { name: 'common.settings.resourceAccess' }),
).toHaveAttribute('href', '/app/app-1/access-config')
expect(
screen.queryByRole('link', { name: 'common.appMenus.overview' }),
).not.toBeInTheDocument()
},
)
it('should hide resource access navigation for Agent apps', () => {
// Arrange
mockAppMode = AppModeEnum.AGENT
mockAppPermissionKeys = [AppACLPermission.AccessConfig]
// Act
render(<AppDetailSection />)
// Assert
expect(screen.getByRole('link', { name: 'common.settings.resourceAccess' })).toHaveAttribute(
'href',
'/app/app-1/access-config',
)
expect(
screen.queryByRole('link', { name: 'common.appMenus.overview' }),
screen.queryByRole('link', { name: 'common.settings.resourceAccess' }),
).not.toBeInTheDocument()
})

View File

@ -101,6 +101,7 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
const supportsAppDeploy = appDetail.mode === AppModeEnum.WORKFLOW
const supportsAnnotations =
appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.COMPLETION
const supportsResourceAccess = appDetail.mode !== AppModeEnum.AGENT
const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, {
currentUserId,
resourceMaintainer: appDetail.maintainer,
@ -165,7 +166,7 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
},
]
: []),
...(appACLCapabilities.canAccessConfig
...(supportsResourceAccess && appACLCapabilities.canAccessConfig
? [
{
name: t(($) => $['settings.resourceAccess'], { ns: 'common' }),

View File

@ -8,6 +8,7 @@ import {
useAppUserAccessSettings,
} from '@/service/access-control/use-app-access-config'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
import { AppACLPermission } from '@/utils/permission'
import AppAccessConfigPage from '../index'
@ -365,6 +366,23 @@ describe('AppAccessConfigPage', () => {
expect(useAppUserAccessSettings).not.toHaveBeenCalled()
})
it('should not mount access config data hooks for Agent apps', () => {
useStore.setState({
appDetail: {
id: 'app-1',
mode: AppModeEnum.AGENT,
maintainer: 'account-1',
permission_keys: [AppACLPermission.AccessConfig],
} as unknown as NonNullable<ReturnType<typeof useStore.getState>['appDetail']>,
})
render(<AppAccessConfigPage appId="app-1" />)
expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument()
expect(useAppAccessRules).not.toHaveBeenCalled()
expect(useAppUserAccessSettings).not.toHaveBeenCalled()
})
it('should allow the maintainer with app management workspace permission', () => {
mockConsoleState.userProfile = { id: 'account-1' }
mockConsoleState.workspacePermissionKeys = ['app.create_and_management']

View File

@ -22,6 +22,7 @@ import {
useUpdateAppAutomaticIncludeWorkspaceMembers,
useUpdateAppUserAccessSettings,
} from '@/service/access-control/use-app-access-config'
import { AppModeEnum } from '@/types/app'
import { getAppACLCapabilities } from '@/utils/permission'
type AppAccessConfigPageProps = {
@ -257,7 +258,13 @@ const AppAccessConfigPage = ({ appId }: AppAccessConfigPageProps) => {
],
)
if (!appDetail || appDetail.id !== appId || !appACLCapabilities.canAccessConfig) return null
if (
!appDetail ||
appDetail.id !== appId ||
appDetail.mode === AppModeEnum.AGENT ||
!appACLCapabilities.canAccessConfig
)
return null
return <AppAccessConfigContent appId={appId} maintainerId={appDetail.maintainer} />
}