diff --git a/api/configs/enterprise/__init__.py b/api/configs/enterprise/__init__.py index 3aeffe4c0f4..ecce58193b8 100644 --- a/api/configs/enterprise/__init__.py +++ b/api/configs/enterprise/__init__.py @@ -14,6 +14,12 @@ class EnterpriseFeatureConfig(BaseSettings): default=False, ) + WEBAPP_PUBLIC_ACCESS_ENABLED: bool = Field( + description="Whether admins are allowed to set a webapp's access mode to public (anyone with the link, " + "no auth). Disable in security-sensitive on-prem deployments.", + default=True, + ) + CAN_REPLACE_LOGO: bool = Field( description="Allow customization of the enterprise logo.", default=False, diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 94bd31486ce..41b486f21a3 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -22940,6 +22940,7 @@ in form definiton, or a variable while the workflow is running. | ---- | ---- | ----------- | -------- | | allow_email_code_login | boolean | | Yes | | allow_email_password_login | boolean | | Yes | +| allow_public_access | boolean,
**Default:** true | | Yes | | allow_sso | boolean | | Yes | | enabled | boolean | | Yes | | sso_config | [WebAppAuthSSOModel](#webappauthssomodel) | | Yes | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 521c126d5b0..44296235014 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -1643,6 +1643,7 @@ in form definiton, or a variable while the workflow is running. | ---- | ---- | ----------- | -------- | | allow_email_code_login | boolean | | Yes | | allow_email_password_login | boolean | | Yes | +| allow_public_access | boolean,
**Default:** true | | Yes | | allow_sso | boolean | | Yes | | enabled | boolean | | Yes | | sso_config | [WebAppAuthSSOModel](#webappauthssomodel) | | Yes | diff --git a/api/services/feature_service.py b/api/services/feature_service.py index f8d69073458..de72792f4d5 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -100,6 +100,7 @@ class WebAppAuthModel(FeatureResponseModel): sso_config: WebAppAuthSSOModel = WebAppAuthSSOModel() allow_email_code_login: bool = False allow_email_password_login: bool = False + allow_public_access: bool = True class KnowledgePipeline(FeatureResponseModel): @@ -286,6 +287,7 @@ class FeatureService: system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP + system_features.webapp_auth.allow_public_access = dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED system_features.enable_step_by_step_tour = dify_config.ENABLE_STEP_BY_STEP_TOUR @classmethod diff --git a/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py b/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py new file mode 100644 index 00000000000..a58aa44d323 --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py @@ -0,0 +1,30 @@ +import pytest + +from services.feature_service import FeatureService, SystemFeatureModel + + +@pytest.mark.parametrize( + ("env_value", "expected"), + [ + (False, False), + (True, True), + ], + ids=["disabled_by_env", "enabled_by_env"], +) +def test_fulfill_system_params_from_env_sets_allow_public_access( + monkeypatch: pytest.MonkeyPatch, + env_value: bool, + expected: bool, +): + monkeypatch.setattr("services.feature_service.dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED", env_value) + + system_features = SystemFeatureModel() + FeatureService._fulfill_system_params_from_env(system_features) + + assert system_features.webapp_auth.allow_public_access is expected + + +def test_get_system_features_defaults_allow_public_access_to_true(): + system_features = FeatureService.get_system_features() + + assert system_features.webapp_auth.allow_public_access is True diff --git a/packages/contracts/generated/api/console/system-features/types.gen.ts b/packages/contracts/generated/api/console/system-features/types.gen.ts index eaf6a11fb87..0417727f5ba 100644 --- a/packages/contracts/generated/api/console/system-features/types.gen.ts +++ b/packages/contracts/generated/api/console/system-features/types.gen.ts @@ -58,6 +58,7 @@ export type PluginManagerModel = { export type WebAppAuthModel = { allow_email_code_login: boolean allow_email_password_login: boolean + allow_public_access: boolean allow_sso: boolean enabled: boolean sso_config: WebAppAuthSsoModel diff --git a/packages/contracts/generated/api/console/system-features/zod.gen.ts b/packages/contracts/generated/api/console/system-features/zod.gen.ts index 402a485925d..3ce9d68841e 100644 --- a/packages/contracts/generated/api/console/system-features/zod.gen.ts +++ b/packages/contracts/generated/api/console/system-features/zod.gen.ts @@ -87,6 +87,7 @@ export const zWebAppAuthSsoModel = z.object({ export const zWebAppAuthModel = z.object({ allow_email_code_login: z.boolean().default(false), allow_email_password_login: z.boolean().default(false), + allow_public_access: z.boolean().default(true), allow_sso: z.boolean().default(false), enabled: z.boolean().default(false), sso_config: zWebAppAuthSsoModel.default({ protocol: '' }), @@ -144,6 +145,7 @@ export const zSystemFeatureModel = z.object({ webapp_auth: zWebAppAuthModel.default({ allow_email_code_login: false, allow_email_password_login: false, + allow_public_access: true, allow_sso: false, enabled: false, sso_config: { protocol: '' }, diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index ad578e8381a..607386d387d 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -567,6 +567,7 @@ export type VerificationTokenResponse = { export type WebAppAuthModel = { allow_email_code_login: boolean allow_email_password_login: boolean + allow_public_access: boolean allow_sso: boolean enabled: boolean sso_config: WebAppAuthSsoModel diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index 093722ed9cc..4dec23a2fe5 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -765,6 +765,7 @@ export const zWebAppAuthSsoModel = z.object({ export const zWebAppAuthModel = z.object({ allow_email_code_login: z.boolean().default(false), allow_email_password_login: z.boolean().default(false), + allow_public_access: z.boolean().default(true), allow_sso: z.boolean().default(false), enabled: z.boolean().default(false), sso_config: zWebAppAuthSsoModel.default({ protocol: '' }), @@ -822,6 +823,7 @@ export const zSystemFeatureModel = z.object({ webapp_auth: zWebAppAuthModel.default({ allow_email_code_login: false, allow_email_password_login: false, + allow_public_access: true, allow_sso: false, enabled: false, sso_config: { protocol: '' }, diff --git a/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx index 16b389155dd..2bd51a9a2d2 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx @@ -47,4 +47,25 @@ describe('AccessControlItem', () => { expect(anyone).toBeChecked() }) + + it('should not select a disabled option', async () => { + const user = userEvent.setup() + render( + aria-label="Access" defaultValue={AccessMode.PUBLIC}> + + Organization Only + + Anyone + , + ) + + const organization = screen.getByRole('radio', { name: 'Organization Only' }) + expect(organization).toHaveAttribute('aria-disabled', 'true') + expect(organization).toHaveClass('cursor-not-allowed') + + await user.click(organization) + + expect(organization).not.toBeChecked() + expect(screen.getByRole('radio', { name: 'Anyone' })).toBeChecked() + }) }) diff --git a/web/app/components/app/app-access-control/__tests__/index.spec.tsx b/web/app/components/app/app-access-control/__tests__/index.spec.tsx index d273ddb0ad8..e83d8730fd2 100644 --- a/web/app/components/app/app-access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/index.spec.tsx @@ -13,6 +13,7 @@ let mockWebappAuth = { allow_sso: true, allow_email_password_login: false, allow_email_code_login: false, + allow_public_access: true, } const render = (ui: ReactElement) => @@ -53,6 +54,7 @@ describe('AccessControl', () => { allow_sso: true, allow_email_password_login: false, allow_email_code_login: false, + allow_public_access: true, } useAccessControlStore.setState({ appId: '', @@ -113,6 +115,7 @@ describe('AccessControl', () => { allow_sso: false, allow_email_password_login: false, allow_email_code_login: false, + allow_public_access: true, } render( @@ -141,4 +144,48 @@ describe('AccessControl', () => { expect(organization).toBeChecked() }) + + describe('public access control', () => { + it('should render the public option enabled without a tooltip when public access is allowed', () => { + render( + , + ) + + const publicOption = screen.getByRole('radio', { + name: /app\.accessControlDialog\.accessItems\.anyone/, + }) + expect(publicOption).not.toHaveAttribute('data-disabled') + expect( + screen.queryByLabelText('app.accessControlDialog.webAppPublicAccessDisabledTip'), + ).not.toBeInTheDocument() + }) + + it('should render the public option disabled with a tooltip when public access is disabled', () => { + mockWebappAuth = { + enabled: true, + allow_sso: true, + allow_email_password_login: false, + allow_email_code_login: false, + allow_public_access: false, + } + + render( + , + ) + + const publicOption = screen.getByRole('radio', { + name: /app\.accessControlDialog\.accessItems\.anyone/, + }) + expect(publicOption).toHaveAttribute('aria-disabled', 'true') + expect( + screen.getByLabelText('app.accessControlDialog.webAppPublicAccessDisabledTip'), + ).toBeInTheDocument() + }) + }) }) diff --git a/web/app/components/app/app-access-control/access-control-item.tsx b/web/app/components/app/app-access-control/access-control-item.tsx index 446fb67ca6e..f4f5d3bb03c 100644 --- a/web/app/components/app/app-access-control/access-control-item.tsx +++ b/web/app/components/app/app-access-control/access-control-item.tsx @@ -6,18 +6,22 @@ import { RadioItem } from '@langgenius/dify-ui/radio' type AccessControlItemProps = PropsWithChildren<{ type: AccessMode + disabled?: boolean }> -export default function AccessControlItem({ type, children }: AccessControlItemProps) { +export default function AccessControlItem({ type, children, disabled }: AccessControlItemProps) { return ( value={type} + disabled={disabled} render={
} className={cn( - 'cursor-pointer rounded-[10px] border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg shadow-xs transition-colors', - 'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover', + 'rounded-[10px] border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg shadow-xs transition-colors', 'focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden', 'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:inset-ring-[0.5px] data-checked:inset-ring-components-option-card-option-selected-border', + disabled + ? 'cursor-not-allowed opacity-60' + : 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover', )} > {children} diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx index f443b80fa72..89a2bac6574 100644 --- a/web/app/components/app/app-access-control/index.tsx +++ b/web/app/components/app/app-access-control/index.tsx @@ -13,6 +13,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { AccessMode, SubjectType } from '@/models/access-control' import { consoleQuery } from '@/service/client' import useAccessControlStore from '../../../../context/access-control-store' +import { Infotip } from '../../base/infotip' import AccessControlDialog from './access-control-dialog' import AccessControlItem from './access-control-item' import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members' @@ -39,6 +40,7 @@ export default function AccessControl(props: AccessControlProps) { (systemFeatures.webapp_auth.allow_sso || systemFeatures.webapp_auth.allow_email_password_login || systemFeatures.webapp_auth.allow_email_code_login) + const publicAccessDisabled = !systemFeatures.webapp_auth.allow_public_access useEffect(() => { setAppId(appId) @@ -48,7 +50,9 @@ export default function AccessControl(props: AccessControlProps) { const { isPending, mutateAsync: updateAccessMode } = useMutation( consoleQuery.enterprise.webAppAuth.updateWebAppWhitelistSubjects.mutationOptions(), ) + const confirmDisabled = isPending || (currentMenu === AccessMode.PUBLIC && publicAccessDisabled) const handleConfirm = useCallback(async () => { + if (confirmDisabled) return const submitData: { appId: string accessMode: AccessMode @@ -70,7 +74,16 @@ export default function AccessControl(props: AccessControlProps) { await updateAccessMode({ body: submitData }) toast.success(t(($) => $['accessControlDialog.updateSuccess'], { ns: 'app' })) onConfirm?.() - }, [updateAccessMode, appId, specificGroups, specificMembers, t, onConfirm, currentMenu]) + }, [ + updateAccessMode, + appId, + specificGroups, + specificMembers, + t, + onConfirm, + currentMenu, + confirmDisabled, + ]) return (
@@ -117,19 +130,29 @@ export default function AccessControl(props: AccessControlProps) { {!hideTip && }
- +

{t(($) => $['accessControlDialog.accessItems.anyone'], { ns: 'app' })}

+ {publicAccessDisabled && ( + $['accessControlDialog.webAppPublicAccessDisabledTip'], { + ns: 'app', + })} + className="h-4 w-4 shrink-0 text-text-warning-secondary hover:text-text-warning-secondary" + > + {t(($) => $['accessControlDialog.webAppPublicAccessDisabledTip'], { ns: 'app' })} + + )}