From 28a036066ae2d7beb9e3400f33af53d7478421c5 Mon Sep 17 00:00:00 2001 From: Xin Zhang Date: Tue, 14 Jul 2026 14:51:19 +0800 Subject: [PATCH] feat(enterprise): enforce licensed seats cap at account creation (#38883) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../console/auth/email_register.py | 6 ++- api/controllers/console/auth/login.py | 10 ++++- api/controllers/console/auth/oauth.py | 4 +- api/controllers/console/error.py | 6 +++ api/controllers/console/workspace/members.py | 10 ++++- api/controllers/openapi/workspaces.py | 3 ++ api/openapi/markdown/console-openapi.md | 1 + api/openapi/markdown/web-openapi.md | 1 + api/services/account_service.py | 12 ++++++ api/services/errors/account.py | 4 ++ api/services/feature_service.py | 6 +++ .../services/test_account_service.py | 27 ++++++++++++ .../console/auth/test_login_logout.py | 42 ++++++++++++++++++- .../test_feature_service_licensed_seats.py | 38 +++++++++++++++++ .../api/console/system-features/types.gen.ts | 5 ++- .../api/console/system-features/zod.gen.ts | 20 ++++++--- .../contracts/generated/api/web/types.gen.ts | 1 + .../contracts/generated/api/web/zod.gen.ts | 10 +++++ web/features/system-features/config.ts | 5 +++ web/vitest.setup.ts | 5 +++ 20 files changed, 203 insertions(+), 13 deletions(-) create mode 100644 api/tests/unit_tests/services/test_feature_service_licensed_seats.py diff --git a/api/controllers/console/auth/email_register.py b/api/controllers/console/auth/email_register.py index d89caa9224f..a9b73ca4679 100644 --- a/api/controllers/console/auth/email_register.py +++ b/api/controllers/console/auth/email_register.py @@ -23,9 +23,9 @@ from libs.password import valid_password from models import Account from services.account_service import AccountService from services.billing_service import BillingService -from services.errors.account import AccountRegisterError +from services.errors.account import AccountRegisterError, SeatsLimitExceededError -from ..error import AccountInFreezeError, EmailSendIpLimitError +from ..error import AccountInFreezeError, EmailSendIpLimitError, SeatsLimitExceeded from ..wraps import email_password_login_enabled, email_register_enabled, setup_required @@ -208,5 +208,7 @@ class EmailRegisterResetApi(Resource): timezone=timezone, session=db.session(), ) + except SeatsLimitExceededError: + raise SeatsLimitExceeded() except AccountRegisterError: raise AccountInFreezeError() diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py index 486f79bcae2..ab92fc0db74 100644 --- a/api/controllers/console/auth/login.py +++ b/api/controllers/console/auth/login.py @@ -30,6 +30,7 @@ from controllers.console.error import ( AccountNotFound, EmailSendIpLimitError, NotAllowedCreateWorkspace, + SeatsLimitExceeded, WorkspacesLimitExceeded, ) from controllers.console.wraps import ( @@ -56,7 +57,12 @@ from models.account import Account from services.account_service import AccountService, InvitationDetailDict, RegisterService, TenantService from services.billing_service import BillingService from services.entities.auth_entities import LoginFailureReason, LoginPayloadBase -from services.errors.account import AccountRegisterError, RefreshTokenAccountNotFoundError, RefreshTokenNotFoundError +from services.errors.account import ( + AccountRegisterError, + RefreshTokenAccountNotFoundError, + RefreshTokenNotFoundError, + SeatsLimitExceededError, +) from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError from services.feature_service import FeatureService @@ -325,6 +331,8 @@ class EmailCodeLoginApi(Resource): ) except WorkSpaceNotAllowedCreateError: raise NotAllowedCreateWorkspace() + except SeatsLimitExceededError: + raise SeatsLimitExceeded() except AccountRegisterError: _log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE) raise AccountInFreezeError() diff --git a/api/controllers/console/auth/oauth.py b/api/controllers/console/auth/oauth.py index 5afafd43131..ffda5b09840 100644 --- a/api/controllers/console/auth/oauth.py +++ b/api/controllers/console/auth/oauth.py @@ -25,7 +25,7 @@ from libs.token import ( from models import Account, AccountStatus from services.account_service import AccountService, RegisterService, TenantService from services.billing_service import BillingService -from services.errors.account import AccountNotFoundError, AccountRegisterError +from services.errors.account import AccountNotFoundError, AccountRegisterError, SeatsLimitExceededError from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError from services.feature_service import FeatureService @@ -182,6 +182,8 @@ class OAuthCallback(Resource): f"{dify_config.CONSOLE_WEB_URL}/signin" "?message=Workspace not found, please contact system admin to invite you to join in a workspace." ) + except SeatsLimitExceededError: + return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Licensed seats limit exceeded.") except AccountRegisterError as e: return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={e.description}") diff --git a/api/controllers/console/error.py b/api/controllers/console/error.py index 0645d63be5f..e4352f92f88 100644 --- a/api/controllers/console/error.py +++ b/api/controllers/console/error.py @@ -58,6 +58,12 @@ class WorkspacesLimitExceeded(BaseHTTPException): code = 400 +class SeatsLimitExceeded(BaseHTTPException): + error_code = "limit_exceeded" + description = "Unable to create account because the licensed seats limit was exceeded" + code = 400 + + class AccountBannedError(BaseHTTPException): error_code = "account_banned" description = "Account is banned." diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index ce4097e24b8..b8788a8af9f 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -40,7 +40,7 @@ from libs.login import current_account_with_tenant, login_required from models.account import Account, TenantAccountJoin, TenantAccountRole from services.account_service import AccountService, RegisterService, TenantService from services.enterprise import rbac_service as enterprise_rbac_service -from services.errors.account import AccountAlreadyInTenantError +from services.errors.account import AccountAlreadyInTenantError, SeatsLimitExceededError from services.feature_service import FeatureService @@ -326,6 +326,14 @@ class MemberInviteEmailApi(Resource): message="Account already in workspace.", ) ) + except SeatsLimitExceededError: + invitation_results.append( + MemberInviteFailedResponse( + status="failed", + email=invitee_email, + message="Licensed seats limit exceeded.", + ) + ) except Exception as e: invitation_results.append( MemberInviteFailedResponse(status="failed", email=invitee_email, message=str(e)) diff --git a/api/controllers/openapi/workspaces.py b/api/controllers/openapi/workspaces.py index 7f8eb0f7012..b53776a48da 100644 --- a/api/controllers/openapi/workspaces.py +++ b/api/controllers/openapi/workspaces.py @@ -48,6 +48,7 @@ from services.errors.account import ( MemberNotInTenantError, NoPermissionError, RoleAlreadyAssignedError, + SeatsLimitExceededError, ) from services.feature_service import FeatureService @@ -190,6 +191,8 @@ class WorkspaceMembersApi(Resource): raise BadRequest(str(exc)) except NoPermissionError as exc: raise BadRequest(str(exc)) + except SeatsLimitExceededError: + raise BadRequest("licensed seats limit exceeded") except AccountRegisterError as exc: raise BadRequest(str(exc)) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index e37ee1f3623..7873eebce85 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -18481,6 +18481,7 @@ Enum class for large language model mode. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | expired_at | string | | Yes | +| seats | [LicenseLimitationModel](#licenselimitationmodel) | | Yes | | status | [LicenseStatus](#licensestatus) | | Yes | | workspaces | [LicenseLimitationModel](#licenselimitationmodel) | | Yes | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index f6812634762..cfd1da2a0ce 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -1301,6 +1301,7 @@ Parsed multipart form fields for HITL uploads. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | expired_at | string | | Yes | +| seats | [LicenseLimitationModel](#licenselimitationmodel) | | Yes | | status | [LicenseStatus](#licensestatus) | | Yes | | workspaces | [LicenseLimitationModel](#licenselimitationmodel) | | Yes | diff --git a/api/services/account_service.py b/api/services/account_service.py index 99a06066ca6..ee7d1feabfd 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -69,6 +69,7 @@ from services.errors.account import ( RefreshTokenAccountNotFoundError, RefreshTokenNotFoundError, RoleAlreadyAssignedError, + SeatsLimitExceededError, TenantNotFoundError, ) from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError @@ -437,6 +438,13 @@ class AccountService: raise AccountNotFound() + # A licensed seat is one Account row, deployment-wide; joining an existing + # account into another workspace does not pass through here and costs no seat. + # is_authenticated=True: server-side enforcement needs the full license payload, + # which the enterprise fill withholds from unauthenticated (browser-facing) calls. + if not FeatureService.get_system_features(is_authenticated=True).license.seats.is_available(): + raise SeatsLimitExceededError("licensed seats limit exceeded") + if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email): raise AccountRegisterError( description=( @@ -1989,6 +1997,10 @@ class RegisterService: session.rollback() logger.exception("Register failed") raise AccountRegisterError("Workspace is not allowed to create.") + except SeatsLimitExceededError: + session.rollback() + logger.exception("Register failed") + raise except AccountRegisterError as are: session.rollback() logger.exception("Register failed") diff --git a/api/services/errors/account.py b/api/services/errors/account.py index 700c1dd4aaf..be421b631e6 100644 --- a/api/services/errors/account.py +++ b/api/services/errors/account.py @@ -45,6 +45,10 @@ class AccountAlreadyInTenantError(BaseServiceError): pass +class SeatsLimitExceededError(BaseServiceError): + pass + + class InvalidActionError(BaseServiceError): pass diff --git a/api/services/feature_service.py b/api/services/feature_service.py index c9d86ee4578..fb4a4844c91 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -79,6 +79,7 @@ class LicenseModel(FeatureResponseModel): status: LicenseStatus = LicenseStatus.NONE expired_at: str = "" workspaces: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0) + seats: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0) class BrandingModel(FeatureResponseModel): @@ -457,6 +458,11 @@ class FeatureService: features.license.workspaces.limit = workspaces_info.get("limit", 0) features.license.workspaces.size = workspaces_info.get("used", 0) + if seats_info := license_info.get("licensedSeats"): + features.license.seats.enabled = seats_info.get("enabled", False) + features.license.seats.limit = seats_info.get("limit", 0) + features.license.seats.size = seats_info.get("used", 0) + if "PluginInstallationPermission" in enterprise_info: plugin_installation_info = enterprise_info["PluginInstallationPermission"] features.plugin_installation_permission.plugin_installation_scope = plugin_installation_info[ diff --git a/api/tests/test_containers_integration_tests/services/test_account_service.py b/api/tests/test_containers_integration_tests/services/test_account_service.py index ac8ed39316b..24895e562e5 100644 --- a/api/tests/test_containers_integration_tests/services/test_account_service.py +++ b/api/tests/test_containers_integration_tests/services/test_account_service.py @@ -17,6 +17,7 @@ from services.errors.account import ( AccountPasswordError, AccountRegisterError, CurrentPasswordIncorrectError, + SeatsLimitExceededError, TenantNotFoundError, ) from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError @@ -477,6 +478,32 @@ class TestAccountService: session=db_session_with_containers, ) + def test_create_account_seats_limit_exceeded( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + """ + Test account creation when the licensed seats limit is exceeded. + """ + fake = Faker() + email = fake.email() + name = fake.name() + password = generate_valid_password(fake) + # Setup mocks + mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies[ + "feature_service" + ].get_system_features.return_value.license.seats.is_available.return_value = False + mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False + + with pytest.raises(SeatsLimitExceededError): + AccountService.create_account( + email=email, + name=name, + interface_language="en-US", + password=password, + session=db_session_with_containers, + ) + def test_link_account_integrate_new_provider( self, db_session_with_containers: Session, mock_external_service_dependencies ): diff --git a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py index 42495d7f63c..ecb3e694cd3 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py +++ b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py @@ -26,10 +26,11 @@ from controllers.console.auth.login import EmailCodeLoginApi, LoginApi, LogoutAp from controllers.console.error import ( AccountBannedError, AccountInFreezeError, + SeatsLimitExceeded, WorkspacesLimitExceeded, ) from services.entities.auth_entities import LoginFailureReason -from services.errors.account import AccountLoginError, AccountPasswordError +from services.errors.account import AccountLoginError, AccountPasswordError, SeatsLimitExceededError def encode_password(password: str) -> str: @@ -487,6 +488,45 @@ class TestLoginApi: assert warn_records[0].args[0] == "user@example.com" assert warn_records[0].args[1] == LoginFailureReason.ACCOUNT_BANNED + @patch("controllers.console.wraps.db") + @patch("controllers.console.auth.login.db") + @patch("controllers.console.auth.login.AccountService.create_account_and_tenant") + @patch("controllers.console.auth.login.AccountService.get_email_code_login_data") + @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token") + @patch("controllers.console.auth.login._get_account_with_case_fallback") + def test_email_code_login_fails_when_seats_limit_exceeded( + self, + mock_get_account: MagicMock, + mock_revoke_token: MagicMock, + mock_get_token_data: MagicMock, + mock_create_account: MagicMock, + mock_login_db: MagicMock, + mock_db: MagicMock, + app: Flask, + ): + """ + Test email-code login failure when creating the account would exceed the licensed seats. + + Verifies that: + - the new-account path is taken when no account exists for the email + - the service-layer SeatsLimitExceededError is translated to the SeatsLimitExceeded HTTP error + """ + # Arrange: valid token, no existing account -> account-creation path + mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"} + mock_get_account.return_value = None + mock_create_account.side_effect = SeatsLimitExceededError("licensed seats limit exceeded") + + # Act & Assert + with app.test_request_context( + "/email-code-login/validity", + method="POST", + json={"email": "User@Example.com", "code": encode_code("123456"), "token": "token-123"}, + ): + with pytest.raises(SeatsLimitExceeded): + EmailCodeLoginApi().post() + + mock_create_account.assert_called_once() + class TestLogoutApi: """Test cases for the LogoutApi endpoint.""" diff --git a/api/tests/unit_tests/services/test_feature_service_licensed_seats.py b/api/tests/unit_tests/services/test_feature_service_licensed_seats.py new file mode 100644 index 00000000000..aa593f6279d --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_licensed_seats.py @@ -0,0 +1,38 @@ +import pytest + +from services import feature_service as feature_service_module +from services.feature_service import FeatureService, SystemFeatureModel + +_ENTERPRISE_INFO = {"License": {"licensedSeats": {"enabled": True, "limit": 3, "used": 1}}} + + +def test_fulfill_params_from_enterprise_parses_licensed_seats(monkeypatch: pytest.MonkeyPatch): + """Authenticated fill copies the licensed-seat quota out of the enterprise payload.""" + monkeypatch.setattr( + feature_service_module.EnterpriseService, + "get_info", + staticmethod(lambda: _ENTERPRISE_INFO), + ) + + features = SystemFeatureModel() + FeatureService._fulfill_params_from_enterprise(features, is_authenticated=True) + + assert features.license.seats.enabled is True + assert features.license.seats.limit == 3 + assert features.license.seats.size == 1 + + +def test_fulfill_params_from_enterprise_withholds_seats_when_unauthenticated(monkeypatch: pytest.MonkeyPatch): + """Seat counts are auth-gated: unauthenticated callers keep the zeroed default.""" + monkeypatch.setattr( + feature_service_module.EnterpriseService, + "get_info", + staticmethod(lambda: _ENTERPRISE_INFO), + ) + + features = SystemFeatureModel() + FeatureService._fulfill_params_from_enterprise(features, is_authenticated=False) + + assert features.license.seats.enabled is False + assert features.license.seats.limit == 0 + assert features.license.seats.size == 0 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 a191d5f596d..17c5b63f2aa 100644 --- a/packages/contracts/generated/api/console/system-features/types.gen.ts +++ b/packages/contracts/generated/api/console/system-features/types.gen.ts @@ -40,6 +40,7 @@ export type BrandingModel = { export type LicenseModel = { expired_at: string + seats: LicenseLimitationModel status: LicenseStatus workspaces: LicenseLimitationModel } @@ -61,14 +62,14 @@ export type WebAppAuthModel = { sso_config: WebAppAuthSsoModel } -export type LicenseStatus = 'active' | 'expired' | 'expiring' | 'inactive' | 'lost' | 'none' - export type LicenseLimitationModel = { enabled: boolean limit: number size: number } +export type LicenseStatus = 'active' | 'expired' | 'expiring' | 'inactive' | 'lost' | 'none' + export type PluginInstallationScope = | 'all' | 'none' 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 e6f2b2fc5a7..4fda744ead4 100644 --- a/packages/contracts/generated/api/console/system-features/zod.gen.ts +++ b/packages/contracts/generated/api/console/system-features/zod.gen.ts @@ -20,11 +20,6 @@ export const zPluginManagerModel = z.object({ enabled: z.boolean().default(false), }) -/** - * LicenseStatus - */ -export const zLicenseStatus = z.enum(['active', 'expired', 'expiring', 'inactive', 'lost', 'none']) - /** * LicenseLimitationModel * @@ -38,11 +33,21 @@ export const zLicenseLimitationModel = z.object({ size: z.int().default(0), }) +/** + * LicenseStatus + */ +export const zLicenseStatus = z.enum(['active', 'expired', 'expiring', 'inactive', 'lost', 'none']) + /** * LicenseModel */ export const zLicenseModel = z.object({ expired_at: z.string().default(''), + seats: zLicenseLimitationModel.default({ + enabled: false, + limit: 0, + size: 0, + }), status: zLicenseStatus.default('none'), workspaces: zLicenseLimitationModel.default({ enabled: false, @@ -114,6 +119,11 @@ export const zSystemFeatureModel = z.object({ is_email_setup: z.boolean().default(false), license: zLicenseModel.default({ expired_at: '', + seats: { + enabled: false, + limit: 0, + size: 0, + }, status: 'none', workspaces: { enabled: false, diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index 9c3fe235c0e..10c5c31e097 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -308,6 +308,7 @@ export type LicenseLimitationModel = { export type LicenseModel = { expired_at: string + seats: LicenseLimitationModel status: LicenseStatus workspaces: LicenseLimitationModel } diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index ff95cd767c7..7d46f57eea3 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -363,6 +363,11 @@ export const zLicenseStatus = z.enum(['active', 'expired', 'expiring', 'inactive */ export const zLicenseModel = z.object({ expired_at: z.string().default(''), + seats: zLicenseLimitationModel.default({ + enabled: false, + limit: 0, + size: 0, + }), status: zLicenseStatus.default('none'), workspaces: zLicenseLimitationModel.default({ enabled: false, @@ -792,6 +797,11 @@ export const zSystemFeatureModel = z.object({ is_email_setup: z.boolean().default(false), license: zLicenseModel.default({ expired_at: '', + seats: { + enabled: false, + limit: 0, + size: 0, + }, status: 'none', workspaces: { enabled: false, diff --git a/web/features/system-features/config.ts b/web/features/system-features/config.ts index 014b40e2cfc..6eb8b7eb2cc 100644 --- a/web/features/system-features/config.ts +++ b/web/features/system-features/config.ts @@ -24,6 +24,11 @@ export const defaultSystemFeatures = { size: 0, limit: 0, }, + seats: { + enabled: false, + size: 0, + limit: 0, + }, }, branding: { enabled: false, diff --git a/web/vitest.setup.ts b/web/vitest.setup.ts index b9ef82b3d0f..e944975a0b8 100644 --- a/web/vitest.setup.ts +++ b/web/vitest.setup.ts @@ -294,6 +294,11 @@ const testSystemFeatures = { size: 0, limit: 0, }, + seats: { + enabled: false, + size: 0, + limit: 0, + }, }, branding: { enabled: false,