feat(enterprise): enforce licensed seats cap at account creation (#38883)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Xin Zhang 2026-07-14 14:51:19 +08:00 committed by GitHub
parent 713c577b51
commit 28a036066a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 203 additions and 13 deletions

View File

@ -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()

View File

@ -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()

View File

@ -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}")

View File

@ -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."

View File

@ -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))

View File

@ -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))

View File

@ -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 |

View File

@ -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 |

View File

@ -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")

View File

@ -45,6 +45,10 @@ class AccountAlreadyInTenantError(BaseServiceError):
pass
class SeatsLimitExceededError(BaseServiceError):
pass
class InvalidActionError(BaseServiceError):
pass

View File

@ -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[

View File

@ -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
):

View File

@ -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."""

View File

@ -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

View File

@ -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'

View File

@ -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,

View File

@ -308,6 +308,7 @@ export type LicenseLimitationModel = {
export type LicenseModel = {
expired_at: string
seats: LicenseLimitationModel
status: LicenseStatus
workspaces: LicenseLimitationModel
}

View File

@ -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,

View File

@ -24,6 +24,11 @@ export const defaultSystemFeatures = {
size: 0,
limit: 0,
},
seats: {
enabled: false,
size: 0,
limit: 0,
},
},
branding: {
enabled: false,

View File

@ -294,6 +294,11 @@ const testSystemFeatures = {
size: 0,
limit: 0,
},
seats: {
enabled: false,
size: 0,
limit: 0,
},
},
branding: {
enabled: false,