diff --git a/api/services/enterprise/rbac_service.py b/api/services/enterprise/rbac_service.py index fd0acf280b2..b511bfbb103 100644 --- a/api/services/enterprise/rbac_service.py +++ b/api/services/enterprise/rbac_service.py @@ -487,6 +487,8 @@ _LEGACY_APP_OWNER_KEYS: list[str] = [ "app.acl.access_config", "app.acl.tracing_config", "app.acl.log_and_annotation", + "app.acl.access_point_manage", + "app.acl.access_point_view", ] _LEGACY_APP_ADMIN_KEYS: list[str] = [ @@ -502,6 +504,8 @@ _LEGACY_APP_ADMIN_KEYS: list[str] = [ "app.acl.access_config", "app.acl.tracing_config", "app.acl.log_and_annotation", + "app.acl.access_point_manage", + "app.acl.access_point_view", ] _LEGACY_APP_EDITOR_KEYS: list[str] = [ @@ -515,10 +519,17 @@ _LEGACY_APP_EDITOR_KEYS: list[str] = [ "app.acl.monitor", "app.acl.log_and_annotation", "app.acl.access_config", + "app.acl.access_point_manage", + "app.acl.access_point_view", ] _LEGACY_APP_NORMAL_KEYS: list[str] = [ "app.acl.monitor", + "app.acl.access_point_view", +] + +_LEGACY_APP_DATASET_OPERATOR_KEYS: list[str] = [ + "app.acl.access_point_view", ] _LEGACY_DATASET_OWNER_KEYS: list[str] = [ @@ -600,6 +611,7 @@ _LEGACY_MY_PERMISSIONS: dict[TenantAccountRole, dict[str, list[str]]] = { }, TenantAccountRole.DATASET_OPERATOR: { "workspace": _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS, + "app": _LEGACY_APP_DATASET_OPERATOR_KEYS, "dataset": _LEGACY_DATASET_DATASET_OPERATOR_KEYS, }, } diff --git a/api/tests/unit_tests/services/enterprise/test_rbac_service.py b/api/tests/unit_tests/services/enterprise/test_rbac_service.py index 0ce4390fd0c..078dbd2c38b 100644 --- a/api/tests/unit_tests/services/enterprise/test_rbac_service.py +++ b/api/tests/unit_tests/services/enterprise/test_rbac_service.py @@ -777,7 +777,7 @@ class TestMyPermissions: ( "dataset_operator", svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS, - [], + svc._LEGACY_APP_DATASET_OPERATOR_KEYS, svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS, ), ], diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx index 8c6acc78866..dc96a55564d 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx @@ -238,9 +238,11 @@ describe('AppDetailLayout', () => { expect(useStore.getState().appDetail?.id).toBe('app-1') }) - it('should allow access point pages without app deploy or app ACL permissions', async () => { + it('should allow users with Access Point view permission to open the page directly', async () => { mockPathname = '/app/app-1/access-point' - mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [] })) + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ permission_keys: [AppACLPermission.AccessPointView] }), + ) render( @@ -254,6 +256,40 @@ describe('AppDetailLayout', () => { expect(useStore.getState().appDetail?.id).toBe('app-1') }) + it('should redirect access point pages when view permission is missing', async () => { + mockPathname = '/app/app-1/access-point' + mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [] })) + + render( + +
App page content
+
, + ) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/apps') + }) + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + expect(useStore.getState().appDetail).toBeUndefined() + }) + + it('should keep cached Access Point content hidden while redirecting without view permission', async () => { + mockPathname = '/app/app-1/access-point' + useStore.getState().setAppDetail(createAppDetail({ permission_keys: [] })) + + render( + +
App page content
+
, + ) + + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/apps') + }) + expect(mockFetchAppDetailDirect).not.toHaveBeenCalled() + }) + it('should redirect deploy pages when app deploy ACL permission is missing', async () => { mockPathname = '/app/app-1/deploy' mockFetchAppDetailDirect.mockResolvedValue( @@ -317,7 +353,7 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/app/app-1/access-point') + expect(mockReplace).toHaveBeenCalledWith('/apps') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() expect(useStore.getState().appDetail).toBeUndefined() @@ -478,7 +514,9 @@ describe('AppDetailLayout', () => { mockIsRbacEnabled = false mockPathname = '/app/app-1/access-config' mockFetchAppDetailDirect.mockResolvedValue( - createAppDetail({ permission_keys: [AppACLPermission.AccessConfig] }), + createAppDetail({ + permission_keys: [AppACLPermission.AccessConfig, AppACLPermission.AccessPointView], + }), ) render( diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 7f9f6973ca8..c56c8ac546c 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -80,6 +80,20 @@ const AppDetailLayout: FC = (props) => { const appName = routeAppDetail?.id === appId ? routeAppDetail.name : undefined const shouldBlockAgentResourceAccess = routeAppDetail?.mode === AppModeEnum.AGENT && pathname.endsWith('/access-config') + const canViewAccessPoint = + routeAppDetail?.id === appId && + currentWorkspace.id && + !isLoadingCurrentWorkspace && + !isLoadingWorkspacePermissionKeys && + !isLoadingAppDetail + ? getAppACLCapabilities(routeAppDetail.permission_keys, { + currentUserId, + resourceMaintainer: routeAppDetail.maintainer, + workspacePermissionKeys, + isRbacEnabled, + }).canViewAccessPoint + : false + const shouldBlockAccessPointAccess = pathname.endsWith('/access-point') && !canViewAccessPoint useDocumentTitle(`${pageTitle} · ${appName || t(($) => $['menus.appDetail'], { ns: 'common' })}`) @@ -141,6 +155,7 @@ const AppDetailLayout: FC = (props) => { const isAnnotationsPath = pathname.endsWith('annotations') const isOverviewPath = pathname.endsWith('overview') const isAccessConfigPath = pathname.endsWith('access-config') + const isAccessPointPath = pathname.endsWith('access-point') const isDeployPath = pathname.endsWith('deploy') if ( (isLayoutPath && !appACLCapabilities.canAccessLayout) || @@ -149,6 +164,7 @@ const AppDetailLayout: FC = (props) => { (isOverviewPath && !appACLCapabilities.canMonitor) || (isAccessConfigPath && (routeAppDetail.mode === AppModeEnum.AGENT || !appACLCapabilities.canAccessConfig)) || + (isAccessPointPath && !appACLCapabilities.canViewAccessPoint) || (isDeployPath && (routeAppDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy)) ) { @@ -198,7 +214,7 @@ const AppDetailLayout: FC = (props) => { const isWorkflowPage = pathname.endsWith('/workflow') const content = - !appDetail || shouldBlockAgentResourceAccess ? ( + !appDetail || shouldBlockAgentResourceAccess || shouldBlockAccessPointAccess ? (
diff --git a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx index e84d925dbdc..ae31b4c5ed2 100644 --- a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx @@ -187,6 +187,8 @@ describe('AppDetailSection', () => { }) it('should render access point navigation using its app route', () => { + mockAppPermissionKeys = [AppACLPermission.AccessPointView] + // Act render() @@ -200,6 +202,14 @@ describe('AppDetailSection', () => { ).not.toBeInTheDocument() }) + it('should hide access point navigation without view permission', () => { + render() + + expect( + screen.queryByRole('link', { name: 'common.appMenus.accessPoint' }), + ).not.toBeInTheDocument() + }) + it('should render deploy navigation with app deploy ACL regardless of the legacy workspace role', () => { // Arrange mockAppMode = 'workflow' diff --git a/web/app/components/app-sidebar/app-detail-section.tsx b/web/app/components/app-sidebar/app-detail-section.tsx index 3176e2cac21..3b2035b23a3 100644 --- a/web/app/components/app-sidebar/app-detail-section.tsx +++ b/web/app/components/app-sidebar/app-detail-section.tsx @@ -120,12 +120,16 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => { }, ] : []), - { - name: t(($) => $['appMenus.accessPoint'], { ns: 'common' }), - href: `/app/${appId}/access-point`, - icon: accessPointNavIcon, - selectedIcon: accessPointNavIcon, - }, + ...(appACLCapabilities.canViewAccessPoint + ? [ + { + name: t(($) => $['appMenus.accessPoint'], { ns: 'common' }), + href: `/app/${appId}/access-point`, + icon: accessPointNavIcon, + selectedIcon: accessPointNavIcon, + }, + ] + : []), ...(supportsAppDeploy && appACLCapabilities.canDeploy ? [ { diff --git a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx index 1e59bb0728e..f4bc42f1d7f 100644 --- a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx +++ b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx @@ -17,11 +17,6 @@ const mocks = vi.hoisted(() => ({ }, webCard: vi.fn(), apiCard: vi.fn(), - capabilities: { - canEdit: false, - canDeploy: true, - canReleaseAndVersion: false, - }, mcpCard: vi.fn(), triggerCard: vi.fn(), useAppWorkflow: vi.fn(), @@ -44,14 +39,6 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { } }) -vi.mock('jotai', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useAtomValue: () => undefined, - } -}) - vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: Record) => unknown) => selector({ appDetail: mocks.appInfo }), @@ -68,10 +55,6 @@ vi.mock('@/service/use-workflow', () => ({ }, })) -vi.mock('@/utils/permission', () => ({ - getAppACLCapabilities: () => mocks.capabilities, -})) - vi.mock('../shared/use-access-point-actions', () => ({ useAccessPointActions: () => ({ handleAppStateChanged: vi.fn(), @@ -124,26 +107,32 @@ describe('BuiltInAccessPoints', () => { isError: false, isPending: false, } - mocks.capabilities = { - canEdit: false, - canDeploy: true, - canReleaseAndVersion: false, - } }) it('renders the unpublished state across all access point cards', () => { - render() + render( + , + ) expect(screen.getByText('deployments.studio.accessPoint.noPublishedTitle')).toBeInTheDocument() expect(mocks.webCard).toHaveBeenCalledWith( - expect.objectContaining({ availability: 'unavailable', canDeploy: true, canEdit: false }), + expect.objectContaining({ + availability: 'unavailable', + canDeploy: true, + canManageAccessPoint: false, + }), ) expect(mocks.apiCard).toHaveBeenCalledWith( expect.objectContaining({ availability: 'unavailable', canManage: false }), ) expect(mocks.mcpCard).toHaveBeenCalledTimes(1) expect(mocks.triggerCard).toHaveBeenCalledWith( - expect.objectContaining({ availability: 'unavailable', canEdit: false }), + expect.objectContaining({ availability: 'unavailable', canManageAccessPoint: false }), ) }) @@ -158,7 +147,14 @@ describe('BuiltInAccessPoints', () => { isPending: false, } - render() + render( + , + ) expect( screen.queryByText('deployments.studio.accessPoint.noPublishedTitle'), @@ -174,20 +170,38 @@ describe('BuiltInAccessPoints', () => { ) }) - it('does not use edit permission to manage the Service API', () => { - mocks.capabilities = { - canEdit: true, - canDeploy: true, - canReleaseAndVersion: false, - } + it('uses Access Point management for every requested built-in operation', () => { + render( + , + ) - render() - - expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ canManage: false })) + expect(mocks.webCard).toHaveBeenCalledWith( + expect.objectContaining({ canManageAccess: false, canManageAccessPoint: true }), + ) + expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ canManage: true })) + expect(mocks.mcpCard).toHaveBeenCalledWith( + expect.objectContaining({ canManageAccessPoint: true }), + ) + expect(mocks.triggerCard).toHaveBeenCalledWith( + expect.objectContaining({ canManageAccessPoint: true }), + ) }) it('highlights only the targeted built-in access point card', () => { - render() + render( + , + ) expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ highlighted: false })) expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ highlighted: false })) @@ -206,7 +220,9 @@ describe('BuiltInAccessPoints', () => { isPending: false, } - render() + render( + , + ) expect(mocks.webCard).toHaveBeenCalledWith( expect.objectContaining({ availability: 'unavailable' }), @@ -232,7 +248,9 @@ describe('BuiltInAccessPoints', () => { isPending: true, } - render() + render( + , + ) expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ availability: 'loading' })) expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ availability: 'loading' })) @@ -248,7 +266,9 @@ describe('BuiltInAccessPoints', () => { isPending: false, } - render() + render( + , + ) expect( screen.queryByText('deployments.studio.accessPoint.noPublishedTitle'), @@ -256,7 +276,9 @@ describe('BuiltInAccessPoints', () => { }) it('does not retry forbidden published workflow requests', () => { - render() + render( + , + ) const options = mocks.useAppWorkflow.mock.calls.at(-1)?.[1] as { retry: (failureCount: number, error: unknown) => boolean diff --git a/web/app/components/app/access-point/__tests__/deployed-environment-access-points.spec.tsx b/web/app/components/app/access-point/__tests__/deployed-environment-access-points.spec.tsx index 3cdf044acc9..d203cb47af7 100644 --- a/web/app/components/app/access-point/__tests__/deployed-environment-access-points.spec.tsx +++ b/web/app/components/app/access-point/__tests__/deployed-environment-access-points.spec.tsx @@ -39,8 +39,8 @@ describe('DeployedEnvironmentAccessPoints', () => { , ) @@ -56,7 +56,12 @@ describe('DeployedEnvironmentAccessPoints', () => { it('renders MCP and Trigger as unsupported without a permanent loading state', () => { render( - , + , ) const mcpCard = screen.getByRole('region', { name: /mcp\.server\.title/ }) @@ -76,4 +81,25 @@ describe('DeployedEnvironmentAccessPoints', () => { expect(card.querySelector('[aria-busy="true"]')).not.toBeInTheDocument() } }) + + it('passes the Built-in permission split to deployed environment cards', () => { + render( + , + ) + + expect(mocks.webAppCard).toHaveBeenCalledWith( + expect.objectContaining({ + canManageAccessPoint: true, + canReleaseAndVersion: false, + }), + ) + expect(mocks.serviceApiCard).toHaveBeenCalledWith( + expect.objectContaining({ canManageAccessPoint: true }), + ) + }) }) diff --git a/web/app/components/app/access-point/__tests__/environment-access-point-cards.spec.tsx b/web/app/components/app/access-point/__tests__/environment-access-point-cards.spec.tsx index efa1ceb37d2..9f35888e191 100644 --- a/web/app/components/app/access-point/__tests__/environment-access-point-cards.spec.tsx +++ b/web/app/components/app/access-point/__tests__/environment-access-point-cards.spec.tsx @@ -228,7 +228,14 @@ describe('environment access point cards', () => { }) it('renders the real environment Web app URL and workflow actions without Embed', async () => { - renderCard() + renderCard( + , + ) expect(await screen.findByText(/env\/workflow\/site-code/)).toHaveTextContent( 'https://site.example.test/env/workflow/site-code', @@ -249,7 +256,14 @@ describe('environment access point cards', () => { access_mode: 'sso_verified', }) - renderCard() + renderCard( + , + ) expect( await screen.findByRole('button', { @@ -261,7 +275,14 @@ describe('environment access point cards', () => { it('shows the environment Web app query as loading instead of failed', () => { mocks.getSite.mockImplementation(() => new Promise(() => {})) - renderCard() + renderCard( + , + ) const card = screen.getByRole('region', { name: /webApp\.title/ }) expect(card).toHaveAttribute('aria-busy', 'true') @@ -273,7 +294,14 @@ describe('environment access point cards', () => { it('uses environment Site mutations for status and URL reset, and opens its access container', async () => { const user = userEvent.setup() - renderCard() + renderCard( + , + ) const accessModeButton = await screen.findByRole('button', { name: /accessControlDialog\.accessItems\.specific/, @@ -313,7 +341,14 @@ describe('environment access point cards', () => { it('opens Customize and Settings with environment endpoint data', async () => { const user = userEvent.setup() - renderCard() + renderCard( + , + ) await screen.findByText(/env\/workflow\/site-code/) await user.click(screen.getByRole('button', { name: /customize\.entry/ })) @@ -325,9 +360,67 @@ describe('environment access point cards', () => { expect(screen.getByRole('dialog', { name: 'environment settings' })).toBeInTheDocument() }) + it('keeps view actions available while disabling deployed Web App management', async () => { + renderCard( + , + ) + + expect(await screen.findByRole('switch')).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('link', { name: /studio\.accessPoint\.open/ })).toBeEnabled() + expect(screen.getByRole('button', { name: /regenerate/ })).toBeDisabled() + expect(screen.getByRole('button', { name: /customize\.entry/ })).toBeDisabled() + expect( + screen.getByRole('button', { name: /accessControlDialog\.accessItems\.specific/ }), + ).toBeDisabled() + expect(screen.getByRole('button', { name: /settings\.settings/ })).toBeDisabled() + }) + + it('uses Access Point management for deployed Web App operations without access management', async () => { + renderCard( + , + ) + + expect(await screen.findByRole('switch')).toBeEnabled() + expect(screen.getByRole('button', { name: /regenerate/ })).toBeEnabled() + expect(screen.getByRole('button', { name: /customize\.entry/ })).toBeEnabled() + expect(screen.getByRole('button', { name: /settings\.settings/ })).toBeEnabled() + expect( + screen.getByRole('button', { name: /accessControlDialog\.accessItems\.specific/ }), + ).toBeDisabled() + }) + + it('uses Web App access management independently from Access Point management', async () => { + renderCard( + , + ) + + expect(await screen.findByRole('switch')).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('button', { name: /regenerate/ })).toBeDisabled() + expect( + screen.getByRole('button', { name: /accessControlDialog\.accessItems\.specific/ }), + ).toBeEnabled() + }) + it('renders the real Service API endpoint, environment keys entry, docs entry, and API toggle', async () => { const user = userEvent.setup() - renderCard() + renderCard( + , + ) expect(await screen.findByText(api.base_url)).toBeInTheDocument() expect(mocks.apiKeyButtonProps).toHaveBeenLastCalledWith( @@ -365,7 +458,9 @@ describe('environment access point cards', () => { enabled: false, }) - renderCard() + renderCard( + , + ) await screen.findByText(api.base_url) expect(await screen.findByRole('button', { name: 'environment-api-keys' })).toBeEnabled() @@ -379,7 +474,9 @@ describe('environment access point cards', () => { it('distinguishes the Service API loading and failed query states', async () => { mocks.getApi.mockRejectedValue(new Error('API unavailable')) - renderCard() + renderCard( + , + ) const card = screen.getByRole('region', { name: /serviceApi\.title/ }) expect(card).toHaveAttribute('aria-busy', 'true') @@ -396,4 +493,22 @@ describe('environment access point cards', () => { expect(screen.getByRole('button', { name: 'environment-api-keys' })).toBeDisabled() expect(screen.getByRole('button', { name: /apiInfo\.doc/ })).toBeDisabled() }) + + it('disables deployed Service API management without Access Point management', async () => { + renderCard( + , + ) + + expect(await screen.findByText(api.base_url)).toBeInTheDocument() + expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('button', { name: 'environment-api-keys' })).toBeDisabled() + expect(screen.getByRole('link', { name: /apiInfo\.doc/ })).toBeEnabled() + expect(mocks.apiKeyButtonProps).toHaveBeenLastCalledWith( + expect.objectContaining({ canManage: false }), + ) + }) }) diff --git a/web/app/components/app/access-point/__tests__/index.spec.tsx b/web/app/components/app/access-point/__tests__/index.spec.tsx index 8785ddd8594..4cfdb2c5637 100644 --- a/web/app/components/app/access-point/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/__tests__/index.spec.tsx @@ -14,7 +14,7 @@ import { AppACLPermission } from '@/utils/permission' import AccessPoint from '..' let appMode = 'workflow' -let appPermissionKeys: string[] = [AppACLPermission.Deploy] +let appPermissionKeys: string[] = [AppACLPermission.AccessPointView] const accessPointMocks = vi.hoisted(() => ({ builtIn: vi.fn(), deployed: vi.fn(), @@ -49,7 +49,13 @@ vi.mock('@/context/permission-state', async () => { }) vi.mock('@/app/components/app/access-point/built-in-access-points', () => ({ - BuiltInAccessPoints: (props: { appId: string; highlightedAccessPoint?: AccessPointType }) => { + BuiltInAccessPoints: (props: { + appId: string + canDeploy: boolean + canManageAccessPoint: boolean + canReleaseAndVersion: boolean + highlightedAccessPoint?: AccessPointType + }) => { accessPointMocks.builtIn(props) return null }, @@ -58,8 +64,8 @@ vi.mock('@/app/components/app/access-point/built-in-access-points', () => ({ vi.mock('@/app/components/app/access-point/deployed-environment-access-points', () => ({ DeployedEnvironmentAccessPoints: (props: { appId: string - canEdit: boolean - canManage: boolean + canManageAccessPoint: boolean + canReleaseAndVersion: boolean environmentId: string highlightedAccessPoint?: AccessPointType }) => { @@ -130,7 +136,7 @@ describe('AccessPoint', () => { beforeEach(() => { vi.clearAllMocks() appMode = 'workflow' - appPermissionKeys = [AppACLPermission.Deploy] + appPermissionKeys = [AppACLPermission.AccessPointView] }) it('renders Built-in and only in-use environments from the API', () => { @@ -210,7 +216,7 @@ describe('AccessPoint', () => { }) }) - it('shows the selected deployed environment with deploy permissions', () => { + it('shows the selected deployed environment with Access Point view permission', () => { renderAccessPoint({ searchParams: '?environment=canary', }) @@ -218,8 +224,8 @@ describe('AccessPoint', () => { expect(accessPointMocks.deployed).toHaveBeenCalledWith( expect.objectContaining({ appId: 'app-1', - canEdit: false, - canManage: true, + canManageAccessPoint: false, + canReleaseAndVersion: false, environmentId: 'canary', }), ) @@ -249,7 +255,7 @@ describe('AccessPoint', () => { expect(accessPointMocks.deployed).not.toHaveBeenCalled() }) - it('falls back to built-in access points without app deploy ACL permission', () => { + it('hides environment tabs without Access Point view permission', () => { appPermissionKeys = [] renderAccessPoint({ @@ -260,4 +266,33 @@ describe('AccessPoint', () => { expect(accessPointMocks.builtIn).toHaveBeenCalledTimes(1) expect(accessPointMocks.deployed).not.toHaveBeenCalled() }) + + it('opens deployed environments with Access Point management independently from deploy', () => { + appPermissionKeys = [AppACLPermission.AccessPointManage] + + renderAccessPoint({ searchParams: '?environment=canary' }) + + expect(screen.getByRole('tab', { name: 'Canary' })).toHaveAttribute('aria-selected', 'true') + expect(accessPointMocks.deployed).toHaveBeenCalledWith( + expect.objectContaining({ + canManageAccessPoint: true, + canReleaseAndVersion: false, + environmentId: 'canary', + }), + ) + }) + + it('passes Web App access management independently from Access Point management', () => { + appPermissionKeys = [AppACLPermission.AccessPointView, AppACLPermission.ReleaseAndVersion] + + renderAccessPoint({ searchParams: '?environment=canary' }) + + expect(accessPointMocks.deployed).toHaveBeenCalledWith( + expect.objectContaining({ + canManageAccessPoint: false, + canReleaseAndVersion: true, + environmentId: 'canary', + }), + ) + }) }) diff --git a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx index e543fde4b45..e4704b39979 100644 --- a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx @@ -94,7 +94,7 @@ describe('MCPAccessPointCard', () => { render( { render( { render( { ) }) - it('disables API management without release permission', () => { + it('disables API management without Access Point management permission', () => { renderWithQueryClient( , ) diff --git a/web/app/components/app/access-point/__tests__/use-access-point-actions.spec.ts b/web/app/components/app/access-point/__tests__/use-access-point-actions.spec.ts index c6a9aff9c67..7d25007c40f 100644 --- a/web/app/components/app/access-point/__tests__/use-access-point-actions.spec.ts +++ b/web/app/components/app/access-point/__tests__/use-access-point-actions.spec.ts @@ -69,9 +69,9 @@ const siteConfig = { use_icon_as_answer_icon: false, } satisfies ConfigParams -function renderActions(appId = 'app-1', canEdit = true) { +function renderActions(appId = 'app-1', canManageAccessPoint = true) { const queryClient = createTestQueryClient() - const rendered = renderHook(() => useAccessPointActions(appId, canEdit), { + const rendered = renderHook(() => useAccessPointActions(appId, canManageAccessPoint), { wrapper: createQueryClientWrapper(queryClient), }) @@ -135,7 +135,7 @@ describe('useAccessPointActions', () => { expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps', 'recent'] }) }) - it('keeps site configuration behind app editing permission', async () => { + it('keeps site configuration behind Access Point management permission', async () => { const { result } = renderActions('app-1', false) await act(async () => { diff --git a/web/app/components/app/access-point/__tests__/web-app-card.spec.tsx b/web/app/components/app/access-point/__tests__/web-app-card.spec.tsx index 57a6e11598a..c1be2f4f69a 100644 --- a/web/app/components/app/access-point/__tests__/web-app-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/web-app-card.spec.tsx @@ -110,10 +110,10 @@ function renderCard( availability: 'available' | 'loading' | 'unavailable' = 'available', workflow?: PublishedWorkflow, { - canEdit = true, + canManageAccessPoint = true, onAppStateChanged = vi.fn().mockResolvedValue(undefined), }: { - canEdit?: boolean + canManageAccessPoint?: boolean onAppStateChanged?: () => Promise } = {}, ) { @@ -122,9 +122,9 @@ function renderCard( { }) }) - it('keeps site status changes behind app editing permission', async () => { + it('keeps site status changes behind Access Point management permission', async () => { const user = userEvent.setup() - renderCard(AppModeEnum.CHAT, 'available', undefined, { canEdit: false }) + renderCard(AppModeEnum.CHAT, 'available', undefined, { canManageAccessPoint: false }) await user.click(screen.getByRole('switch')) @@ -332,4 +332,18 @@ describe('WebAppAccessPointCard', () => { screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'), ).not.toBeInTheDocument() }) + + it('disables Web App management actions without Access Point management', () => { + renderCard(AppModeEnum.CHAT, 'available', undefined, { canManageAccessPoint: false }) + + expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('button', { name: /embedIntoSite/ })).toBeDisabled() + expect(screen.getByRole('button', { name: /customize\.entry/ })).toBeDisabled() + expect(screen.getByRole('button', { name: /settings\.settings/ })).toBeDisabled() + expect(screen.getByRole('button', { name: /regenerate/ })).toBeDisabled() + expect(screen.getByRole('link', { name: /studio\.accessPoint\.open/ })).toBeEnabled() + expect( + screen.getByRole('button', { name: /accessControlDialog\.accessItems\.anyone/ }), + ).toBeEnabled() + }) }) diff --git a/web/app/components/app/access-point/built-in-access-points/index.tsx b/web/app/components/app/access-point/built-in-access-points/index.tsx index 9245f3c63be..b516f0d5b45 100644 --- a/web/app/components/app/access-point/built-in-access-points/index.tsx +++ b/web/app/components/app/access-point/built-in-access-points/index.tsx @@ -4,18 +4,13 @@ import type { AccessPoint } from '@/app/components/app/deploy/access-point' import { Button, buttonVariants } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' -import { useAtomValue } from 'jotai' -import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import Loading from '@/app/components/base/loading' import { useDocLink } from '@/context/i18n' -import { workspacePermissionKeysAtom } from '@/context/permission-state' -import { userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' import { useAppWorkflow } from '@/service/use-workflow' -import { getAppACLCapabilities } from '@/utils/permission' import { useAccessPointActions } from '../shared/use-access-point-actions' import { getPublishedWorkflowState, isAdvancedApp } from '../shared/utils' import { MCPAccessPointCard } from './mcp-card' @@ -25,18 +20,22 @@ import { WebAppAccessPointCard } from './web-app-card' type BuiltInAccessPointsProps = { appId: string + canDeploy: boolean + canManageAccessPoint: boolean + canReleaseAndVersion: boolean highlightedAccessPoint?: AccessPoint | null } -export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAccessPointsProps) { +export function BuiltInAccessPoints({ + appId, + canDeploy, + canManageAccessPoint, + canReleaseAndVersion, + highlightedAccessPoint, +}: BuiltInAccessPointsProps) { const { t } = useTranslation() const docLink = useDocLink() const appInfo = useAppStore((state) => state.appDetail) - const { data: currentUserId } = useSuspenseQuery({ - ...userProfileQueryOptions(), - select: (data) => data.profile.id, - }) - const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo)) const { @@ -50,16 +49,7 @@ export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAc return failureCount < 3 }, }) - const capabilities = useMemo( - () => - getAppACLCapabilities(appInfo?.permission_keys, { - currentUserId, - resourceMaintainer: appInfo?.maintainer, - workspacePermissionKeys, - }), - [appInfo?.maintainer, appInfo?.permission_keys, currentUserId, workspacePermissionKeys], - ) - const actions = useAccessPointActions(appId, capabilities.canEdit) + const actions = useAccessPointActions(appId, canManageAccessPoint) if (!appInfo) return @@ -94,7 +84,7 @@ export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAc })} - {capabilities.canReleaseAndVersion ? ( + {canReleaseAndVersion ? ( diff --git a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx index 611d699d3af..dbca8400485 100644 --- a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx +++ b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx @@ -28,7 +28,7 @@ import { getPublishedWorkflowNodes, isAdvancedApp } from '../shared/utils' type MCPAccessPointCardProps = { appInfo: AccessPointAppInfo - canEdit: boolean + canManageAccessPoint: boolean highlighted?: boolean triggerModeDisabled: boolean workflow: PublishedWorkflow @@ -37,7 +37,7 @@ type MCPAccessPointCardProps = { export function MCPAccessPointCard({ appInfo, - canEdit, + canManageAccessPoint, highlighted, triggerModeDisabled, workflow, @@ -99,7 +99,7 @@ export function MCPAccessPointCard({ }, [advancedApp, basicAppInputs, workflowNodes]) const handleStatusChange = async (enabled: boolean) => { - if (!canEdit || loading || unavailable) return + if (!canManageAccessPoint || loading || unavailable) return if (enabled && !serverPublished) { setShowServerModal(true) return @@ -121,7 +121,7 @@ export function MCPAccessPointCard({ } const handleRegenerate = async () => { - if (!canEdit || !detail?.id) return + if (!canManageAccessPoint || !detail?.id) return await refreshServerCode(appInfo.id) invalidateServerDetail(appInfo.id) setShowRegenerate(false) @@ -145,14 +145,14 @@ export function MCPAccessPointCard({ icon="i-custom-vender-integrations-mcp" status={status} highlighted={highlighted} - switchDisabled={!canEdit} + switchDisabled={!canManageAccessPoint} switchLabel={t(($) => $['mcp.server.title'], { ns: 'tools' })} switchLoading={statusUpdating} onEnabledChange={loading || unavailable ? undefined : handleStatusChange} actions={