mirror of
https://github.com/langgenius/dify.git
synced 2026-07-20 09:38:32 +08:00
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:
parent
713c577b51
commit
28a036066a
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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}")
|
||||
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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 |
|
||||
|
||||
|
||||
@ -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 |
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -45,6 +45,10 @@ class AccountAlreadyInTenantError(BaseServiceError):
|
||||
pass
|
||||
|
||||
|
||||
class SeatsLimitExceededError(BaseServiceError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidActionError(BaseServiceError):
|
||||
pass
|
||||
|
||||
|
||||
@ -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[
|
||||
|
||||
@ -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
|
||||
):
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -308,6 +308,7 @@ export type LicenseLimitationModel = {
|
||||
|
||||
export type LicenseModel = {
|
||||
expired_at: string
|
||||
seats: LicenseLimitationModel
|
||||
status: LicenseStatus
|
||||
workspaces: LicenseLimitationModel
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -24,6 +24,11 @@ export const defaultSystemFeatures = {
|
||||
size: 0,
|
||||
limit: 0,
|
||||
},
|
||||
seats: {
|
||||
enabled: false,
|
||||
size: 0,
|
||||
limit: 0,
|
||||
},
|
||||
},
|
||||
branding: {
|
||||
enabled: false,
|
||||
|
||||
@ -294,6 +294,11 @@ const testSystemFeatures = {
|
||||
size: 0,
|
||||
limit: 0,
|
||||
},
|
||||
seats: {
|
||||
enabled: false,
|
||||
size: 0,
|
||||
limit: 0,
|
||||
},
|
||||
},
|
||||
branding: {
|
||||
enabled: false,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user