refactor(api): consolidate account modules and shared errors (#42570)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Byron.wangandautofix-ci[bot] authored and GitHub committed 2026-09-30 07:26:44 +00:00
1 parent 6decc78fd5
commit 95373bc4fd
251 files changed
+9691 -14618

No files matched your search

+31 -13
View File
@@ -61,7 +61,6 @@ ignore_imports =
core.app.features.annotation_reply.annotation_reply -> services.annotation_service
core.app.features.annotation_reply.annotation_reply -> services.knowledge.dataset_service
core.app.llm.quota -> services.credit_pool_service
core.app.task_pipeline.message_cycle_manager -> services.account_service
core.app.task_pipeline.message_cycle_manager -> services.annotation_service
core.app.workflow.layers.persistence -> services.workflow.inspector_events
core.helper.credential_utils -> services.enterprise.plugin_manager_service
@@ -72,9 +71,7 @@ ignore_imports =
core.rag.datasource.retrieval_service -> services.knowledge.external.service
core.rag.index_processor.index_processor -> services.vector_space_admission_service
core.rag.index_processor.index_processor_base -> services.file_service
core.rag.index_processor.processor.paragraph_index_processor -> services.account_service
core.rag.index_processor.processor.paragraph_index_processor -> services.knowledge.summaries.adapters
core.rag.index_processor.processor.parent_child_index_processor -> services.account_service
core.rag.index_processor.processor.parent_child_index_processor -> services.knowledge.summaries.adapters
core.rag.index_processor.processor.qa_index_processor -> services.knowledge.summaries.adapters
core.rag.retrieval.dataset_retrieval -> services.knowledge.external.service
@@ -115,8 +112,6 @@ ignore_imports =
libs.oauth_bearer -> models
libs.rsa -> extensions.ext_storage
libs.workspace_permission -> services.enterprise.enterprise_service
services.account_service -> controllers
services.account_service -> controllers.console.error
services.app_generate_service -> controllers.console.app.workflow
[importlinter:contract:no-direct-rsa-imports]
@@ -156,16 +151,22 @@ forbidden_modules =
werkzeug
[importlinter:contract:workspace-query-service-boundary]
name = Workspace query application service is framework and persistence neutral
name = Workspace application services and contracts are framework and persistence neutral
type = forbidden
source_modules =
services.workspace_query_service
services.workspace.service
services.workspace.provisioning_service
services.workspace.contracts
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
services.billing_service
services.feature_service
services.workspace.gateways
sqlalchemy
werkzeug
@@ -431,16 +432,16 @@ source_modules =
services.account_avatar_service
services.account_change_email_ports
services.account_change_email_service
services.account_email_registration_service
services.account.email_registration_service
services.account_deletion_service
services.account_deletion_feedback_service
services.account_education_service
services.account_errors
services.account_forgot_password_service
services.account.forgot_password_service
services.account_initialization_service
services.account_integration_service
services.account_login_service
services.account_oauth_service
services.account.login_service
services.account.oauth_service
services.account_password_service
services.account_ports
services.account_profile_service
@@ -696,7 +697,7 @@ forbidden_modules =
name = Workspace member query application service is framework and persistence neutral
type = forbidden
source_modules =
services.workspace_member_query_service
services.workspace.member_service
forbidden_modules =
configs
controllers
@@ -705,7 +706,7 @@ forbidden_modules =
models
repositories
services.enterprise
services.workspace_member_role_resolver
services.workspace.gateways
sqlalchemy
werkzeug
@@ -1060,3 +1061,20 @@ forbidden_modules =
services.audio_service
sqlalchemy
werkzeug
[importlinter:contract:account-lifecycle-boundary]
name = Account lifecycle use cases and contracts are framework and persistence neutral
type = forbidden
source_modules =
services.account.service
services.account.contracts
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
services.account.adapters
sqlalchemy
werkzeug
+8 -29
View File
@@ -1,15 +1,11 @@
import base64
import secrets
import click
from sqlalchemy.orm import Session
from constants.languages import languages
from extensions.ext_database import db
from extensions.ext_application_services import application_services
from libs.helper import email as email_validate
from libs.password import hash_password, password_pattern, valid_password
from services.account_email import normalize_email
from services.account_service import AccountService, RegisterService, TenantService
from libs.password import password_pattern, valid_password
@click.command("reset-password", help="Reset the account password.")
@@ -26,7 +22,7 @@ def reset_password(email, new_password, password_confirm):
return
normalized_email = email.strip().lower()
account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=db.session())
account = application_services().accounts.lifecycle.get_account_by_email_with_case_fallback(email.strip())
if not account:
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
@@ -38,19 +34,7 @@ def reset_password(email, new_password, password_confirm):
click.echo(click.style(f"Invalid password. Must match {password_pattern}", fg="red"))
return
# generate password salt
salt = secrets.token_bytes(16)
base64_salt = base64.b64encode(salt).decode()
# encrypt password with salt
password_hashed = hash_password(new_password, salt)
base64_password_hashed = base64.b64encode(password_hashed).decode()
with Session(db.engine) as session:
account = session.merge(account)
account.password = base64_password_hashed
account.password_salt = base64_salt
session.commit()
AccountService.reset_login_error_rate_limit(normalized_email)
application_services().accounts.lifecycle.reset_password(account.id, new_password, email=normalized_email)
click.echo(click.style("Password reset successfully.", fg="green"))
@@ -68,7 +52,7 @@ def reset_email(email, new_email, email_confirm):
return
normalized_new_email = new_email.strip().lower()
account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=db.session())
account = application_services().accounts.lifecycle.get_account_by_email_with_case_fallback(email.strip())
if not account:
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
@@ -80,11 +64,7 @@ def reset_email(email, new_email, email_confirm):
click.echo(click.style(f"Invalid email: {new_email}", fg="red"))
return
with Session(db.engine) as session:
account = session.merge(account)
account.email = normalized_new_email
account.normalized_email = normalize_email(normalized_new_email)
session.commit()
application_services().accounts.lifecycle.set_email(account.id, normalized_new_email)
click.echo(click.style("Email updated successfully.", fg="green"))
@@ -129,15 +109,14 @@ def create_tenant(email: str, language: str | None = None, name: str | None = No
return
# register account
account = RegisterService.register(
account = application_services().accounts.lifecycle.register(
email=email,
name=account_name,
password=new_password,
language=language,
create_workspace_required=False,
session=db.session(),
)
TenantService.create_owner_tenant_if_not_exist(account, name, session=db.session())
application_services().workspaces.provisioning.create_owner_workspace(account.id, name=name, if_missing=True)
click.echo(
click.style(
+24
View File
@@ -1,4 +1,12 @@
from collections import OrderedDict
from http import HTTPStatus
from typing import cast
from flask import current_app, got_request_exception
from flask_restx import Api
from libs.exception import BaseHTTPException
from services.errors.base import NoPermissionError
class FilenameNotExistsError(BaseHTTPException):
@@ -87,3 +95,19 @@ class NotFoundError(BaseHTTPException):
class InvalidArgumentError(BaseHTTPException):
error_code = "invalid_param"
code = 400
def register_permission_error_handler(api: Api) -> None:
"""Preserve the default permission-error response for Console and Service API."""
@api.errorhandler(NoPermissionError)
def handle_permission_error(error: NoPermissionError) -> tuple[dict[str, str | int], int]:
got_request_exception.send(current_app, exception=error)
return {
"code": InvalidArgumentError.error_code,
"message": str(error),
"status": HTTPStatus.BAD_REQUEST,
}, HTTPStatus.BAD_REQUEST
# Flask-RESTX picks the first matching handler, including the generic Exception handler.
cast(OrderedDict[type[Exception], object], api.error_handlers).move_to_end(NoPermissionError, last=False)
+2
View File
@@ -6,6 +6,7 @@ from typing import cast
from flask import Blueprint, current_app, got_request_exception
from flask_restx import Namespace
from controllers.common.errors import register_permission_error_handler
from libs.external_api import ExternalApi
from machinery.errors import ActiveWorkspaceRequiredError
@@ -17,6 +18,7 @@ api = ExternalApi(
title="Console API",
description="Console management APIs for app configuration, monitoring, and administration",
)
register_permission_error_handler(api)
@api.errorhandler(ActiveWorkspaceRequiredError)
+1 -1
View File
@@ -24,7 +24,7 @@ from services.auth.api_key_contracts import (
ApiKeyNotFoundError,
ApiKeyResourceNotFoundError,
)
from services.errors.account import NoPermissionError
from services.errors.base import NoPermissionError
from services.knowledge.api_key_service import UnknownDatasetIdsError
API_KEY_EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
+1 -1
View File
@@ -79,7 +79,7 @@ from services.entities.app_entities import (
UpdateAppParams,
)
from services.entities.dsl_entities import ImportStatus
from services.errors.account import NoPermissionError
from services.errors.base import NoPermissionError
from services.knowledge.entities.knowledge_entities import (
DataSource,
InfoList,
+1 -1
View File
@@ -17,7 +17,7 @@ from models.account import TenantAccountRole
from services.agent.errors import InvalidRosterAgentPackageError
from services.app.console_service import ConsoleAppNotFoundError
from services.entities.dsl_entities import AppImportParams, CheckDependenciesResult, Import, ImportStatus
from services.errors.account import NoPermissionError
from services.errors.base import NoPermissionError
from services.recommended_app_query_service import RecommendedAppNotFoundError
from .. import console_ns
@@ -16,6 +16,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.member_fields import AccountWithRole
@@ -31,7 +32,6 @@ from models.comment import (
from models.comment import (
WorkflowCommentReply as WorkflowCommentReplyModel,
)
from services.account_service import TenantService
from services.workflow_comment_service import WorkflowCommentService
logger = logging.getLogger(__name__)
@@ -585,7 +585,7 @@ class WorkflowCommentMentionUsersApi(Resource):
current_tenant = current_user.current_tenant # need the tenant object here
if current_tenant is None:
raise ValueError("current tenant is required")
members = TenantService.get_tenant_members(current_tenant, session=db.session())
members = application_services().workspaces.member_queries.list_members(current_tenant.id)
users = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
response = WorkflowCommentMentionUsersPayload(users=users)
return response.model_dump(mode="json"), 200
+4 -10
View File
@@ -12,14 +12,8 @@ from extensions.ext_application_services import application_services
from libs.helper import EmailStr, dump_response, timezone
from libs.login import current_account_with_tenant
from libs.token import extract_access_token
from services.account_activation_service import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.account_activation_service import (
FrozenAccountError,
InvalidInvitationError,
InvitationAccountMismatchError,
)
from services.account_errors import AccountEmailDomainSuspendedError as EmailDomainSuspendedRegistrationError
from services.account_errors import FrozenAccountError, InvalidInvitationError, InvitationAccountMismatchError
from services.entities.account_activation_entities import ActivationCommand, InvitationLookup
@@ -91,7 +85,7 @@ class ActivateCheckApi(Resource):
)
@model_validate(ActivateCheckQuery)
def get(self, args: ActivateCheckQuery):
result = application_services().account_activation.check(
result = application_services().accounts.activation.check(
InvitationLookup(
workspace_id=args.workspace_id,
email=args.email,
@@ -128,7 +122,7 @@ class ActivateApi(Resource):
authenticated_account_id = current_account_with_tenant().account.id
try:
application_services().account_activation.activate(
application_services().accounts.activation.activate(
ActivationCommand(
invitation=InvitationLookup(
workspace_id=args.workspace_id,
@@ -154,7 +154,7 @@ class EmailRegisterResetApi(Resource):
@model_validate(EmailRegisterResetPayload)
def post(self, args: EmailRegisterResetPayload):
try:
token_pair = application_services().accounts.email_registration.register(
tokens = application_services().accounts.email_registration.register(
remote_ip=extract_remote_ip(request),
token=args.token,
new_password=args.new_password,
@@ -179,5 +179,5 @@ class EmailRegisterResetApi(Resource):
return dump_response(
EmailRegisterResetResponse,
{"result": "success", "data": token_pair},
{"result": "success", "data": tokens},
)
+12 -16
View File
@@ -60,12 +60,8 @@ from libs.token import (
set_refresh_token_to_cookie,
)
from services import account_errors
from services.entities.account_login_entities import (
AuthTokenPair,
EmailCodeLoginCommand,
EmailCodeSendCommand,
PasswordLoginCommand,
)
from services.entities.account_entities import AccountSessionTokens
from services.entities.account_login_entities import EmailCodeLoginCommand, EmailCodeSendCommand, PasswordLoginCommand
from services.entities.auth_entities import LoginPayloadBase
@@ -141,7 +137,7 @@ class LoginApi(Resource):
except account_errors.AccountApplicationError as error:
_raise_request_error(error)
if not result.workspace_found or result.token_pair is None:
if not result.workspace_found or result.tokens is None:
return dump_response(
SimpleResultOptionalDataResponse,
{
@@ -149,7 +145,7 @@ class LoginApi(Resource):
"data": "workspace not found, please contact system admin to invite you to join in a workspace",
},
)
return _token_response(result.token_pair, SimpleResultOptionalDataResponse, {"result": "success"})
return _token_response(result.tokens, SimpleResultOptionalDataResponse, {"result": "success"})
@console_ns.route("/logout")
@@ -218,7 +214,7 @@ class EmailCodeLoginApi(Resource):
@model_validate(EmailCodeLoginPayload)
def post(self, req_data: EmailCodeLoginPayload):
try:
token_pair = application_services().accounts.authentication.login_with_email_code(
tokens = application_services().accounts.authentication.login_with_email_code(
EmailCodeLoginCommand(
email=req_data.email,
code=req_data.code,
@@ -231,7 +227,7 @@ class EmailCodeLoginApi(Resource):
)
except account_errors.AccountApplicationError as error:
_raise_request_error(error)
return _token_response(token_pair, SimpleResultResponse, {"result": "success"})
return _token_response(tokens, SimpleResultResponse, {"result": "success"})
@console_ns.route("/refresh-token")
@@ -247,21 +243,21 @@ class RefreshTokenApi(Resource):
), HTTPStatus.UNAUTHORIZED
try:
token_pair = application_services().accounts.authentication.refresh(refresh_token)
tokens = application_services().accounts.authentication.refresh(refresh_token)
except account_errors.InvalidRefreshTokenError as error:
return dump_response(
SimpleResultMessageResponse,
{"result": "fail", "message": str(error)},
), HTTPStatus.UNAUTHORIZED
return _token_response(token_pair, SimpleResultResponse, {"result": "success"})
return _token_response(tokens, SimpleResultResponse, {"result": "success"})
def _token_response(token_pair: AuthTokenPair, response_model: type[BaseModel], data: object):
def _token_response(tokens: AccountSessionTokens, response_model: type[BaseModel], data: object):
# response-contract:ignore cookie-bearing Flask response
response = make_response(dump_response(response_model, data))
set_csrf_token_to_cookie(request, response, token_pair.csrf_token)
set_access_token_to_cookie(request, response, token_pair.access_token)
set_refresh_token_to_cookie(request, response, token_pair.refresh_token)
set_csrf_token_to_cookie(request, response, tokens.csrf_token)
set_access_token_to_cookie(request, response, tokens.access_token)
set_refresh_token_to_cookie(request, response, tokens.refresh_token)
return response
@@ -5,7 +5,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
import services
import services.errors.base
from controllers.console.app.error import (
CompletionRequestError,
ProviderModelCurrentlyNotSupportError,
@@ -97,7 +97,7 @@ class DatasetsHitTestingBase:
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
except services.errors.base.NoPermissionError as e:
raise Forbidden(str(e))
return dataset
@@ -37,7 +37,7 @@ from models.account import Account
from models.dataset import Pipeline
from repositories.knowledge.dataset_read_repository import get_pipeline_dataset
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, PipelineTemplateInfoEntity
from services.errors.account import NoPermissionError
from services.errors.base import NoPermissionError
from services.errors.rag_pipeline import RagPipelineResourceNotFoundError
from services.knowledge.dataset_service import DatasetService
from services.rag_pipeline.rag_pipeline import RagPipelineService
@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field, RootModel, ValidationError
from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services
import services.errors.base
from configs import dify_config
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
from controllers.common.fields import SimpleResultResponse
@@ -1088,7 +1088,7 @@ class RagPipelineTransformApi(Resource):
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as exc:
except services.errors.base.NoPermissionError as exc:
raise Forbidden(str(exc)) from exc
try:
+31 -16
View File
@@ -10,22 +10,32 @@ from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.rbac import RBAC_CHECKS_ATTR, RBACCheck, enforce_rbac_checks
from controllers.console.admin import admin_required
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_resource_check,
enable_change_email,
enterprise_license_required,
is_allow_transfer_owner,
setup_required,
)
from core.logging.context import get_request_id, get_trace_id
from enums import DeploymentEdition
from enums.account import TenantAccountRole
from libs.login import current_account_with_tenant, login_required
from libs.oauth_bearer import bearer_feature_required
from machinery.context import RequestContext
from models.account import TenantAccountRole
from services.system_feature_service import SystemFeatureService
def console_admin_admission[T, **P, R](
view: Callable[Concatenate[T, P], R],
) -> Callable[Concatenate[T, P], R]:
"""Admit a configured admin API key without requiring a Console account."""
return setup_required(admin_required(view))
def console_email_registration_admission[T, **P, R](
view: Callable[Concatenate[T, P], R],
) -> Callable[Concatenate[T, P], R | Response]:
@@ -49,28 +59,25 @@ def console_account_admission[T, **P, R](
require_change_email_enabled: bool = False,
require_initialized: bool = True,
require_valid_enterprise_license: bool = False,
require_owner_transfer_enabled: bool = False,
billing_resource: str | None = None,
require_oauth_bearer_enabled: bool = False,
allowed_roles: frozenset[TenantAccountRole] | None = None,
rbac_checks: Sequence[RBACCheck] | None = None,
billing_resource: str | None = None,
) -> Callable[
[Callable[Concatenate[T, RequestContext, P], R]],
Callable[Concatenate[T, P], R | Response],
]:
"""Declare Console account admission and inject a stable RequestContext.
All combinations use this decorator factory. Requirements are data, while
the execution order stays fixed: edition, setup, login/CSRF, optional
account initialization, optional enterprise license and feature checks,
role/RBAC checks, then context construction and optional billing-resource
admission.
Checks run in a fixed order: setup, login/CSRF, optional account
initialization, edition, license, feature and billing checks, role/RBAC
checks, then context construction.
"""
def decorator(
view: Callable[Concatenate[T, RequestContext, P], R],
) -> Callable[Concatenate[T, P], R | Response]:
admitted_view = cloud_edition_billing_resource_check(billing_resource)(view) if billing_resource else view
@wraps(view)
def inject_request_context(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
account_with_tenant = current_account_with_tenant()
@@ -91,32 +98,40 @@ def console_account_admission[T, **P, R](
request_id=get_request_id(),
trace_id=get_trace_id() or request.headers.get("X-Trace-Id"),
)
return admitted_view(self, request_context, *args, **kwargs)
return view(self, request_context, *args, **kwargs)
if rbac_checks is not None:
setattr(inject_request_context, RBAC_CHECKS_ATTR, rbac_checks)
admitted: Callable[Concatenate[T, P], R | Response] = inject_request_context
if billing_resource is not None:
admitted = cloud_edition_billing_resource_check(billing_resource)(admitted)
if require_change_email_enabled:
admitted = enable_change_email(admitted)
if require_owner_transfer_enabled:
admitted = is_allow_transfer_owner(admitted)
if require_oauth_bearer_enabled:
admitted = bearer_feature_required(admitted)
if require_valid_enterprise_license:
admitted = enterprise_license_required(admitted)
if editions is not None:
admitted = enforce_edition(admitted)
if require_initialized:
admitted = account_initialization_required(admitted)
admitted = login_required(admitted)
admitted = setup_required(admitted)
if editions is None:
return admitted
return admitted
@wraps(view)
def enforce_edition(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R | Response:
if dify_config.DEPLOYMENT_EDITION not in editions:
def enforce_edition(
admitted: Callable[Concatenate[T, P], R | Response],
) -> Callable[Concatenate[T, P], R | Response]:
@wraps(admitted)
def checked(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R | Response:
if editions is not None and dify_config.DEPLOYMENT_EDITION not in editions:
abort(HTTPStatus.NOT_FOUND)
return admitted(self, *args, **kwargs)
return enforce_edition
return checked
return decorator
+2 -5
View File
@@ -7,11 +7,8 @@ from controllers.fastopenapi import console_router
from extensions.ext_application_services import application_services
from libs.helper import EmailStr, extract_remote_ip
from libs.password import valid_password
from services.setup_service import (
InitializationValidationRequiredError,
SetupAlreadyCompletedError,
SetupInput,
)
from services.account.contracts import SetupInput
from services.setup_service import InitializationValidationRequiredError, SetupAlreadyCompletedError
from .error import AlreadySetupError, NotInitValidateError
from .init_validate import is_init_validated
+3 -3
View File
@@ -5,11 +5,11 @@ from typing import cast
from flask import Request as FlaskRequest
from core.db.session_factory import session_factory
from extensions.ext_application_services import application_services
from extensions.ext_socketio import sio
from libs.passport import PassportService
from libs.token import extract_access_token
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository
from services.account_service import AccountService
from services.workflow_collaboration_service import WorkflowCollaborationService
repository = WorkflowCollaborationRepository()
@@ -43,8 +43,8 @@ def socket_connect(sid, environ, auth):
logging.warning("Socket connect rejected: missing user_id (sid=%s)", sid)
return False
with sio.app.app_context(), session_factory.create_session() as session:
user = AccountService.load_logged_in_account(account_id=user_id, session=session)
with sio.app.app_context():
user = application_services().accounts.identity.load_user(user_id)
if not user:
logging.warning("Socket connect rejected: user not found (user_id=%s, sid=%s)", user_id, sid)
return False
+147 -346
View File
@@ -1,15 +1,13 @@
from http import HTTPStatus
from typing import Annotated, Literal
from typing import Annotated, Literal, NoReturn
from urllib import parse
from uuid import UUID
from flask import abort, request
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func, select
from werkzeug.exceptions import NotFound
from werkzeug.exceptions import Forbidden, NotFound
import services
from configs import dify_config
from controllers.common.fields import SimpleResultDataResponse, SimpleResultResponse, VerificationTokenResponse
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
@@ -22,30 +20,25 @@ from controllers.console.auth.error import (
MemberNotInTenantError,
NotOwnerError,
OwnerTransferLimitError,
OwnerTransferRateLimitExceededError,
)
from controllers.console.error import EmailSendIpLimitError, SeatsLimitExceeded, WorkspaceMembersLimitExceeded
from controllers.console.flask_admission import console_account_admission
from controllers.console.workspace.error import InvalidMemberRoleError
from controllers.console.wraps import (
account_initialization_required,
is_allow_transfer_owner,
setup_required,
with_current_user,
)
from enums import DeploymentEdition
from enums.account import TenantAccountRole
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.base import ResponseModel
from fields.member_fields import AccountWithRoleListResponse, AccountWithRoleResponse
from libs.helper import dump_response, extract_remote_ip
from libs.login import login_required
from machinery.context import RequestContext
from models.account import Account, TenantAccountJoin, TenantAccountRole
from services.account_service import AccountService, RegisterService, TenantService
from services.errors.account import AccountAlreadyInTenantError
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
from services.account_errors import AccountNotFoundError
from services.errors import workspace as workspace_errors
from services.errors.base import NoPermissionError
from services.errors.workspace import (
InvalidWorkspaceMemberRoleError,
WorkspaceApplicationError,
WorkspaceInvitationQuotaError,
)
class MemberInvitePayload(BaseModel):
@@ -142,62 +135,6 @@ register_response_schema_models(
)
def _is_role_enabled(role: TenantAccountRole | str) -> bool:
if role != TenantAccountRole.DATASET_OPERATOR:
return True
return dify_config.DATASET_OPERATOR_ENABLED
def _count_new_member_invites(tenant_id: str, emails: list[str]) -> tuple[int, int]:
new_member_count = 0
new_account_count = 0
for email in emails:
account = AccountService.get_account_by_email_with_case_fallback(email, session=db.session())
if not account:
new_member_count += 1
new_account_count += 1
continue
exists = db.session.scalar(
select(TenantAccountJoin.id)
.where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.account_id == account.id)
.limit(1)
)
if not exists:
new_member_count += 1
return new_member_count, new_account_count
def _count_current_members(tenant_id: str) -> int:
return (
db.session.scalar(select(func.count(TenantAccountJoin.id)).where(TenantAccountJoin.tenant_id == tenant_id)) or 0
)
def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_account_count: int) -> None:
if new_member_count <= 0:
return
features = FeatureService.get_features(tenant_id=tenant_id, exclude_vector_space=True)
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
workspace_members = features.workspace_members
if workspace_members.enabled is True and not workspace_members.is_available(new_member_count):
raise WorkspaceMembersLimitExceeded()
if new_account_count > 0:
seats = SystemFeatureService.get_license().seats
if not seats.is_available(new_account_count):
raise SeatsLimitExceeded()
return
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
members = features.members
current_member_count = _count_current_members(tenant_id)
if 0 < members.limit < current_member_count + new_member_count:
raise WorkspaceMembersLimitExceeded()
@console_ns.route("/workspaces/current/members")
class MemberListApi(Resource):
"""List all members of current tenant."""
@@ -205,7 +142,7 @@ class MemberListApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountWithRoleListResponse.__name__])
@console_account_admission()
def get(self, request_context: RequestContext):
members = application_services().workspace_member_queries.list_current(request_context)
members = application_services().workspaces.member_queries.list_current(request_context)
serialized_members = [
{
"id": member.id,
@@ -235,123 +172,68 @@ class MemberInviteEmailApi(Resource):
"Invalid role or workspace member limit exceeded",
console_ns.models[MemberInviteErrorResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
args = MemberInvitePayload.model_validate(payload)
invitee_emails = args.emails
invitee_role = args.role
interface_language = args.language
if not dify_config.RBAC_ENABLED:
if not TenantAccountRole.is_valid_role(invitee_role):
raise InvalidMemberRoleError()
if not TenantAccountRole.is_non_owner_role(TenantAccountRole(invitee_role)):
raise InvalidMemberRoleError()
inviter = current_user
if not inviter.current_tenant:
raise ValueError("No current tenant")
if not _is_role_enabled(invitee_role):
raise InvalidMemberRoleError()
# Check workspace permission for member invitations
from libs.workspace_permission import check_workspace_member_invite_permission
check_workspace_member_invite_permission(inviter.current_tenant.id)
invitation_results: list[MemberInviteResultResponse] = []
console_web_url = dify_config.CONSOLE_WEB_URL
tenant_id = inviter.current_tenant.id
with redis_client.lock(f"workspace_member_invite:{tenant_id}", timeout=60):
if dify_config.DEPLOYMENT_EDITION in {
DeploymentEdition.CLOUD,
DeploymentEdition.ENTERPRISE,
}:
new_member_count, new_account_count = _count_new_member_invites(tenant_id, invitee_emails)
_check_member_invite_limits(tenant_id, new_member_count, new_account_count)
for invitee_email in invitee_emails:
try:
if not inviter.current_tenant:
raise ValueError("No current tenant")
token = RegisterService.invite_new_member(
tenant=inviter.current_tenant,
email=invitee_email,
language=interface_language,
role=invitee_role,
inviter=inviter,
session=db.session(),
@console_account_admission()
def post(self, request_context: RequestContext):
args = MemberInvitePayload.model_validate(console_ns.payload or {})
try:
results = application_services().workspaces.invitations.invite_many(
request_context,
emails=args.emails,
language=args.language,
role=args.role,
)
except InvalidWorkspaceMemberRoleError:
raise InvalidMemberRoleError() from None
except NoPermissionError as error:
raise Forbidden(str(error)) from error
except WorkspaceInvitationQuotaError as error:
if error.seats:
raise SeatsLimitExceeded() from error
raise WorkspaceMembersLimitExceeded() from error
serialized: list[MemberInviteResultResponse] = []
for result in results:
if result.status == "success":
email = parse.quote(result.email)
serialized.append(
MemberInviteSuccessResponse(
status="success",
email=result.email,
url=f"{dify_config.CONSOLE_WEB_URL}/activate?email={email}&token={result.token}",
)
encoded_invitee_email = parse.quote(invitee_email)
invitation_results.append(
MemberInviteSuccessResponse(
status="success",
email=invitee_email,
url=f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}",
)
)
elif result.status == "already_member":
serialized.append(
MemberInviteAlreadyMemberResponse(
status="already_member", email=result.email, message=result.message or ""
)
except AccountAlreadyInTenantError:
invitation_results.append(
MemberInviteAlreadyMemberResponse(
status="already_member",
email=invitee_email,
message="Account already in workspace.",
)
)
except Exception as e:
invitation_results.append(
MemberInviteFailedResponse(status="failed", email=invitee_email, message=str(e))
)
return (
dump_response(
MemberInviteResponse,
{
"result": "success",
"invitation_results": invitation_results,
"tenant_id": inviter.current_tenant.id if inviter.current_tenant else "",
},
),
HTTPStatus.CREATED,
)
)
else:
serialized.append(
MemberInviteFailedResponse(status="failed", email=result.email, message=result.message or "")
)
return dump_response(
MemberInviteResponse,
{"result": "success", "invitation_results": serialized, "tenant_id": request_context.active_workspace_id},
), HTTPStatus.CREATED
@console_ns.route("/workspaces/current/members/<uuid:member_id>")
class MemberCancelInviteApi(Resource):
"""Cancel an invitation by member id."""
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[MemberActionResponse.__name__])
@with_current_user
def delete(self, current_user: Account, member_id: UUID):
if not current_user.current_tenant:
raise ValueError("No current tenant")
member = db.session.get(Account, str(member_id))
if member is None:
abort(HTTPStatus.NOT_FOUND)
else:
try:
TenantService.remove_member_from_tenant(
current_user.current_tenant, member, current_user, session=db.session()
)
except services.errors.account.CannotOperateSelfError as e:
return {"code": "cannot-operate-self", "message": str(e)}, HTTPStatus.BAD_REQUEST
except services.errors.account.NoPermissionError as e:
return {"code": "forbidden", "message": str(e)}, HTTPStatus.FORBIDDEN
except services.errors.account.MemberNotInTenantError as e:
return {"code": "member-not-found", "message": str(e)}, HTTPStatus.NOT_FOUND
except Exception as e:
raise ValueError(str(e))
@console_account_admission()
def delete(self, request_context: RequestContext, member_id: UUID):
try:
application_services().workspaces.members.remove(
request_context.active_workspace_id, str(member_id), request_context.account_id
)
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
return _member_mutation_error_response(error)
return MemberActionResponse(
result="success",
tenant_id=current_user.current_tenant.id if current_user.current_tenant else "",
tenant_id=request_context.active_workspace_id,
).model_dump(mode="json"), HTTPStatus.OK
@@ -361,40 +243,16 @@ class MemberUpdateRoleApi(Resource):
@console_ns.expect(console_ns.models[MemberRoleUpdatePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
def put(self, current_user: Account, member_id: UUID):
@console_account_admission()
def put(self, request_context: RequestContext, member_id: UUID):
payload = console_ns.payload or {}
args = MemberRoleUpdatePayload.model_validate(payload)
new_role = args.role
if not TenantAccountRole.is_valid_role(new_role):
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not _is_role_enabled(new_role):
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
member = db.session.get(Account, str(member_id))
if not member:
abort(HTTPStatus.NOT_FOUND)
try:
assert member is not None, "Member not found"
TenantService.update_member_role(
current_user.current_tenant, member, new_role, current_user, session=db.session()
application_services().workspaces.members.update_role(
request_context.active_workspace_id, str(member_id), args.role, request_context.account_id
)
except services.errors.account.CannotOperateSelfError as e:
return {"code": "cannot-operate-self", "message": str(e)}, HTTPStatus.BAD_REQUEST
except services.errors.account.NoPermissionError as e:
return {"code": "forbidden", "message": str(e)}, HTTPStatus.FORBIDDEN
except services.errors.account.MemberNotInTenantError as e:
return {"code": "member-not-found", "message": str(e)}, HTTPStatus.NOT_FOUND
except services.errors.account.RoleAlreadyAssignedError as e:
return {"code": "role-already-assigned", "message": str(e)}, HTTPStatus.BAD_REQUEST
except Exception as e:
raise ValueError(str(e))
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
return _member_mutation_error_response(error)
return SimpleResultResponse(result="success").model_dump(mode="json")
@@ -403,15 +261,12 @@ class MemberUpdateRoleApi(Resource):
class DatasetOperatorMemberListApi(Resource):
"""List all members of current tenant."""
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountWithRoleListResponse.__name__])
@with_current_user
def get(self, current_user: Account):
if not current_user.current_tenant:
raise ValueError("No current tenant")
members = TenantService.get_dataset_operator_members(current_user.current_tenant, session=db.session())
@console_account_admission()
def get(self, request_context: RequestContext):
members = application_services().workspaces.member_queries.list_members(
request_context.active_workspace_id, dataset_operators_only=True
)
return dump_response(AccountWithRoleListResponse, {"accounts": members}), HTTPStatus.OK
@@ -421,37 +276,17 @@ class SendOwnerTransferEmailApi(Resource):
@console_ns.expect(console_ns.models[OwnerTransferEmailPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_allow_transfer_owner
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
args = OwnerTransferEmailPayload.model_validate(payload)
ip_address = extract_remote_ip(request)
if AccountService.is_email_send_ip_limit(ip_address):
raise EmailSendIpLimitError()
# check if the current user is the owner of the workspace
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session()):
raise NotOwnerError()
if args.language is not None and args.language == "zh-Hans":
language = "zh-Hans"
else:
language = "en-US"
email = current_user.email
token = AccountService.send_owner_transfer_email(
account=current_user,
email=email,
language=language,
workspace_name=current_user.current_tenant.name if current_user.current_tenant else "",
)
@console_account_admission(require_owner_transfer_enabled=True)
def post(self, request_context: RequestContext):
args = OwnerTransferEmailPayload.model_validate(console_ns.payload or {})
try:
token = application_services().workspaces.owner_transfer.send_code(
request_context,
ip_address=extract_remote_ip(request),
language=args.language,
)
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
_raise_owner_transfer_error(error)
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@@ -459,107 +294,73 @@ class SendOwnerTransferEmailApi(Resource):
class OwnerTransferCheckApi(Resource):
@console_ns.expect(console_ns.models[OwnerTransferCheckPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[VerificationTokenResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_allow_transfer_owner
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
args = OwnerTransferCheckPayload.model_validate(payload)
# check if the current user is the owner of the workspace
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session()):
raise NotOwnerError()
user_email = current_user.email
is_owner_transfer_error_rate_limit = AccountService.is_owner_transfer_error_rate_limit(user_email)
if is_owner_transfer_error_rate_limit:
raise OwnerTransferLimitError()
token_data = AccountService.get_owner_transfer_data(args.token)
if token_data is None:
raise InvalidTokenError()
if user_email != token_data.get("email"):
raise InvalidEmailError()
if args.code != token_data.get("code"):
AccountService.add_owner_transfer_error_rate_limit(user_email)
raise EmailCodeError()
# Verified, revoke the first token
AccountService.revoke_owner_transfer_token(args.token)
# Refresh token data by generating a new token
_, new_token = AccountService.generate_owner_transfer_token(user_email, code=args.code, additional_data={})
AccountService.reset_owner_transfer_error_rate_limit(user_email)
return VerificationTokenResponse(is_valid=True, email=user_email, token=new_token).model_dump(mode="json")
@console_account_admission(require_owner_transfer_enabled=True)
def post(self, request_context: RequestContext):
args = OwnerTransferCheckPayload.model_validate(console_ns.payload or {})
try:
email, token = application_services().workspaces.owner_transfer.verify_code(
request_context, token=args.token, code=args.code
)
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
_raise_owner_transfer_error(error)
return VerificationTokenResponse(is_valid=True, email=email, token=token).model_dump(mode="json")
@console_ns.route("/workspaces/current/members/<uuid:member_id>/owner-transfer")
class OwnerTransfer(Resource):
@console_ns.expect(console_ns.models[OwnerTransferPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_allow_transfer_owner
@with_current_user
def post(self, current_user: Account, member_id: UUID):
payload = console_ns.payload or {}
args = OwnerTransferPayload.model_validate(payload)
# check if the current user is the owner of the workspace
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session()):
raise NotOwnerError()
if current_user.id == str(member_id):
raise CannotTransferOwnerToSelfError()
transfer_token_data = AccountService.get_owner_transfer_data(args.token)
if not transfer_token_data:
raise InvalidTokenError()
if transfer_token_data.get("email") != current_user.email:
raise InvalidEmailError()
AccountService.revoke_owner_transfer_token(args.token)
member = db.session.get(Account, str(member_id))
if not member:
raise NotFound()
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not TenantService.is_member(member, current_user.current_tenant, session=db.session()):
raise MemberNotInTenantError()
@console_account_admission(require_owner_transfer_enabled=True)
def post(self, request_context: RequestContext, member_id: UUID):
args = OwnerTransferPayload.model_validate(console_ns.payload or {})
try:
assert member is not None, "Member not found"
TenantService.update_member_role(
current_user.current_tenant, member, "owner", current_user, session=db.session()
application_services().workspaces.owner_transfer.transfer(
request_context, member_id=str(member_id), token=args.token
)
AccountService.send_new_owner_transfer_notify_email(
account=member,
email=member.email,
workspace_name=current_user.current_tenant.name if current_user.current_tenant else "",
)
AccountService.send_old_owner_transfer_notify_email(
account=current_user,
email=current_user.email,
workspace_name=current_user.current_tenant.name if current_user.current_tenant else "",
new_owner_email=member.email,
)
except Exception as e:
raise ValueError(str(e))
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
_raise_owner_transfer_error(error)
return SimpleResultResponse(result="success").model_dump(mode="json")
def _member_mutation_error_response(
error: WorkspaceApplicationError | AccountNotFoundError | NoPermissionError,
) -> tuple[dict[str, str], HTTPStatus]:
if isinstance(error, (AccountNotFoundError, workspace_errors.WorkspaceNotFoundError)):
raise NotFound() from error
if isinstance(error, InvalidWorkspaceMemberRoleError):
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
if isinstance(error, workspace_errors.CannotOperateSelfError):
return {"code": "cannot-operate-self", "message": str(error)}, HTTPStatus.BAD_REQUEST
if isinstance(error, NoPermissionError):
return {"code": "forbidden", "message": str(error)}, HTTPStatus.FORBIDDEN
if isinstance(error, workspace_errors.MemberNotInTenantError):
return {"code": "member-not-found", "message": str(error)}, HTTPStatus.NOT_FOUND
if isinstance(error, workspace_errors.RoleAlreadyAssignedError):
return {"code": "role-already-assigned", "message": str(error)}, HTTPStatus.BAD_REQUEST
raise error
def _raise_owner_transfer_error(
error: WorkspaceApplicationError | AccountNotFoundError | NoPermissionError,
) -> NoReturn:
if isinstance(error, NoPermissionError):
raise NotOwnerError() from error
if isinstance(error, (AccountNotFoundError, workspace_errors.WorkspaceNotFoundError)):
raise NotFound() from error
if isinstance(error, workspace_errors.OwnerTransferSendIPLimitedError):
raise EmailSendIpLimitError() from error
if isinstance(error, workspace_errors.OwnerTransferSendRateLimitError):
raise OwnerTransferRateLimitExceededError(error.retry_after_minutes) from error
if isinstance(error, workspace_errors.OwnerTransferVerificationLimitError):
raise OwnerTransferLimitError() from error
if isinstance(error, workspace_errors.InvalidOwnerTransferTokenError):
raise InvalidTokenError() from error
if isinstance(error, workspace_errors.InvalidOwnerTransferEmailError):
raise InvalidEmailError() from error
if isinstance(error, workspace_errors.InvalidOwnerTransferCodeError):
raise EmailCodeError() from error
if isinstance(error, workspace_errors.CannotOperateSelfError):
raise CannotTransferOwnerToSelfError() from error
if isinstance(error, workspace_errors.MemberNotInTenantError):
raise MemberNotInTenantError() from error
raise error
@@ -4,12 +4,10 @@ from typing import Any, Literal
from flask import send_file
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from controllers.common.fields import SimpleResultResponse, ValidationResultResponse
from controllers.common.rbac import RBACCheck, Workspace
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.wraps import (
RBACPermission,
@@ -21,6 +19,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
@@ -34,7 +33,6 @@ from services.entities.model_provider_entities import (
ProviderResponse,
)
from services.model_provider.service import ModelProviderService
from services.workspace_service import WorkspaceService
class ParserModelList(BaseModel):
@@ -191,9 +189,8 @@ class ModelProviderCreditsApi(Resource):
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str):
credit_pool = WorkspaceService.get_effective_credit_pool(tenant_id, session=session)
def get(self, tenant_id: str):
credit_pool = application_services().workspaces.management.get_effective_credit_pool(tenant_id)
return dump_response(ModelProviderCreditsResponse, credit_pool)
+72 -209
View File
@@ -1,16 +1,11 @@
import logging
from datetime import datetime
from http import HTTPStatus
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
import services
from configs import dify_config
from controllers.common.errors import (
FilenameNotExistsError,
FileTooLargeError,
@@ -24,35 +19,19 @@ from controllers.common.schema import (
register_response_schema_models,
register_schema_models,
)
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.admin import admin_required
from controllers.console.error import AccountNotLinkTenantError
from controllers.console.flask_admission import console_account_admission
from controllers.console.flask_admission import console_account_admission, console_admin_admission
from controllers.console.workspace.error import CurrentWorkspaceArchivedError
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_resource_check,
only_edition_enterprise,
setup_required,
with_current_tenant_id,
with_current_user,
)
from enums import CloudPlan
from enums import CloudPlan, DeploymentEdition
from enums.account import TenantAccountRole
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from libs.pagination import paginate_query
from machinery.context import RequestContext
from models.account import Account, Tenant, TenantAccountRole, TenantCustomConfigDict, TenantStatus
from services.account_service import TenantService
from services.enterprise.enterprise_service import EnterpriseService
from services.file_service import FileService
from services.workspace_service import WorkspaceService
logger = logging.getLogger(__name__)
from services.errors import file as file_errors
from services.errors.workspace import WorkspaceArchivedError, WorkspaceNotFoundError, WorkspaceNotLinkedError
from services.workspace.contracts import WorkspaceCustomConfigChanges
class WorkspaceListQuery(BaseModel):
@@ -231,7 +210,7 @@ class TenantListApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TenantListResponse.__name__])
@console_account_admission()
def get(self, request_context: RequestContext):
workspaces = application_services().workspace_queries.list_for_account(request_context)
workspaces = application_services().workspaces.queries.list_for_account(request_context)
return dump_response(TenantListResponse, {"workspaces": workspaces}), HTTPStatus.OK
@@ -239,207 +218,108 @@ class TenantListApi(Resource):
class WorkspaceListApi(Resource):
@console_ns.doc(params=query_params_from_model(WorkspaceListQuery))
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspacePaginationResponse.__name__])
@setup_required
@admin_required
@with_session(write=False)
def get(self, session: Session):
@console_admin_admission
def get(self):
args = query_params_from_request(WorkspaceListQuery)
stmt = select(Tenant).order_by(Tenant.created_at.desc())
tenants = paginate_query(stmt, session=session, page=args.page, per_page=args.limit)
has_more = False
if tenants.has_next:
has_more = True
return WorkspacePaginationResponse(
data=tenants.items, has_more=has_more, limit=args.limit, page=args.page, total=tenants.total or 0
).model_dump(mode="json"), HTTPStatus.OK
result = application_services().workspaces.management.list_all(page=args.page, limit=args.limit)
return dump_response(WorkspacePaginationResponse, result), HTTPStatus.OK
@console_ns.route("/workspaces/current/summary")
class CurrentWorkspaceSummaryApi(Resource):
@console_ns.response(
HTTPStatus.OK,
"Success",
console_ns.models[CurrentWorkspaceSummaryResponse.__name__],
)
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[CurrentWorkspaceSummaryResponse.__name__])
@console_ns.response(HTTPStatus.CONFLICT, "Current workspace is archived")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_session(write=False)
def get(self, session: Session, current_user: Account):
tenant = current_user.current_tenant
if not tenant:
raise ValueError("No current tenant")
if tenant.status == TenantStatus.ARCHIVE:
raise CurrentWorkspaceArchivedError()
return (
dump_response(
CurrentWorkspaceSummaryResponse,
WorkspaceService.get_current_workspace_summary(tenant, current_user.id, session=session),
),
HTTPStatus.OK,
)
@console_account_admission()
def get(self, context: RequestContext):
try:
summary = application_services().workspaces.management.current_summary(context)
except WorkspaceArchivedError as exc:
raise CurrentWorkspaceArchivedError() from exc
except WorkspaceNotFoundError as exc:
raise NotFound() from exc
return dump_response(CurrentWorkspaceSummaryResponse, summary), HTTPStatus.OK
@console_ns.route("/workspaces/switch")
class SwitchWorkspaceApi(Resource):
@console_ns.expect(console_ns.models[SwitchWorkspacePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SwitchWorkspaceResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_session
def post(self, session: Session, current_user: Account):
payload = console_ns.payload or {}
args = SwitchWorkspacePayload.model_validate(payload)
# Check whether the tenant_id belongs to the current account.
@console_account_admission()
def post(self, context: RequestContext):
args = SwitchWorkspacePayload.model_validate(console_ns.payload or {})
try:
TenantService.switch_tenant(current_user, args.tenant_id, session=session)
except Exception as e:
raise AccountNotLinkTenantError("Account not link tenant") from e
new_tenant = TenantService.get_tenant_by_id(args.tenant_id, session=session)
if new_tenant is None:
raise ValueError("Tenant not found")
return SwitchWorkspaceResponse(
result="success", new_tenant=WorkspaceService.get_tenant_info(new_tenant, session=session)
).model_dump(mode="json")
tenant = application_services().workspaces.management.switch(context, args.tenant_id)
except WorkspaceNotLinkedError as exc:
raise AccountNotLinkTenantError("Account not link tenant") from exc
except WorkspaceNotFoundError as exc:
raise NotFound() from exc
return dump_response(SwitchWorkspaceResponse, {"result": "success", "new_tenant": tenant})
@console_ns.route("/workspaces/custom-config")
class CustomConfigWorkspaceApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspaceCustomConfigResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, current_tenant_id: str):
tenant = TenantService.get_tenant_by_id(current_tenant_id, session=session)
if tenant is None:
raise NotFound()
custom_config = tenant.custom_config_dict
replace_webapp_logo = (
f"{dify_config.FILES_URL}/files/workspaces/{tenant.id}/webapp-logo"
if custom_config.get("replace_webapp_logo")
else None
)
return dump_response(
WorkspaceCustomConfigResponse,
{
"remove_webapp_brand": custom_config.get("remove_webapp_brand", False),
"replace_webapp_logo": replace_webapp_logo,
},
)
@console_account_admission()
def get(self, context: RequestContext):
try:
config = application_services().workspaces.management.custom_config(context)
except WorkspaceNotFoundError as exc:
raise NotFound() from exc
return dump_response(WorkspaceCustomConfigResponse, config)
@console_ns.expect(console_ns.models[WorkspaceCustomConfigPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspaceTenantResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_resource_check("workspace_custom")
@with_current_tenant_id
@with_session
def post(self, session: Session, current_tenant_id: str):
payload = console_ns.payload or {}
args = WorkspaceCustomConfigPayload.model_validate(payload)
tenant = TenantService.get_tenant_by_id(current_tenant_id, session=session)
if tenant is None:
raise NotFound()
custom_config_dict: TenantCustomConfigDict = {
"remove_webapp_brand": args.remove_webapp_brand
if args.remove_webapp_brand is not None
else tenant.custom_config_dict.get("remove_webapp_brand", False),
"replace_webapp_logo": args.replace_webapp_logo
if args.replace_webapp_logo is not None
else tenant.custom_config_dict.get("replace_webapp_logo"),
}
tenant.custom_config_dict = custom_config_dict
session.commit()
return WorkspaceTenantResultResponse(
result="success", tenant=WorkspaceService.get_tenant_info(tenant, session=session)
).model_dump(mode="json")
@console_account_admission(billing_resource="workspace_custom")
def post(self, context: RequestContext):
args = WorkspaceCustomConfigPayload.model_validate(console_ns.payload or {})
try:
tenant = application_services().workspaces.management.update_custom_config(
context,
WorkspaceCustomConfigChanges(
remove_webapp_brand=args.remove_webapp_brand, replace_webapp_logo=args.replace_webapp_logo
),
)
except WorkspaceNotFoundError as exc:
raise NotFound() from exc
return dump_response(WorkspaceTenantResultResponse, {"result": "success", "tenant": tenant})
@console_ns.route("/workspaces/custom-config/webapp-logo/upload")
class WebappLogoWorkspaceApi(Resource):
@console_ns.doc(consumes=["multipart/form-data"], params=WORKSPACE_LOGO_UPLOAD_PARAMS)
@console_ns.response(HTTPStatus.CREATED, "Logo uploaded", console_ns.models[WorkspaceLogoUploadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_resource_check("workspace_custom")
@with_current_user
def post(self, current_user: Account):
# check file
@console_account_admission(billing_resource="workspace_custom")
def post(self, context: RequestContext):
if "file" not in request.files:
raise NoFileUploadedError()
if len(request.files) > 1:
raise TooManyFilesError()
# get file from request
file = request.files["file"]
if not file.filename:
raise FilenameNotExistsError
extension = file.filename.split(".")[-1]
if extension.lower() not in {"svg", "png"}:
raise UnsupportedFileTypeError()
raise FilenameNotExistsError()
try:
upload_file = FileService(db.engine).upload_file(
filename=file.filename,
content=file.stream.read(),
mimetype=file.mimetype,
user=current_user,
file_id = application_services().workspaces.management.upload_logo(
context, filename=file.filename, content=file.stream.read(), mimetype=file.mimetype
)
except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description)
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
return WorkspaceLogoUploadResponse(id=upload_file.id).model_dump(mode="json"), HTTPStatus.CREATED
except file_errors.FileTooLargeError as exc:
raise FileTooLargeError(exc.description) from exc
except file_errors.UnsupportedFileTypeError as exc:
raise UnsupportedFileTypeError() from exc
return dump_response(WorkspaceLogoUploadResponse, {"id": file_id}), HTTPStatus.CREATED
@console_ns.route("/workspaces/info")
class WorkspaceInfoApi(Resource):
@console_ns.expect(console_ns.models[WorkspaceInfoPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspaceTenantResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
# Change workspace name
@with_current_tenant_id
@with_session
def post(self, session: Session, current_tenant_id: str):
payload = console_ns.payload or {}
args = WorkspaceInfoPayload.model_validate(payload)
if not current_tenant_id:
raise ValueError("No current tenant")
tenant = TenantService.get_tenant_by_id(current_tenant_id, session=session)
if tenant is None:
raise NotFound()
tenant.name = args.name
session.commit()
return WorkspaceTenantResultResponse(
result="success", tenant=WorkspaceService.get_tenant_info(tenant, session=session)
).model_dump(mode="json")
@console_account_admission()
def post(self, context: RequestContext):
args = WorkspaceInfoPayload.model_validate(console_ns.payload or {})
try:
tenant = application_services().workspaces.management.rename(context, args.name)
except WorkspaceNotFoundError as exc:
raise NotFound() from exc
return dump_response(WorkspaceTenantResultResponse, {"result": "success", "tenant": tenant})
@console_ns.route("/workspaces/current/permission")
@@ -447,24 +327,7 @@ class WorkspacePermissionApi(Resource):
"""Get workspace permissions for the current workspace."""
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspacePermissionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@only_edition_enterprise
@with_current_tenant_id
def get(self, current_tenant_id: str):
"""
Get workspace permission settings.
Returns permission flags that control workspace features like member invitations and owner transfer.
"""
if not current_tenant_id:
raise ValueError("No current tenant")
# Get workspace permissions from enterprise service
permission = EnterpriseService.WorkspacePermissionService.get_permission(current_tenant_id)
return WorkspacePermissionResponse(
workspace_id=permission.workspace_id,
allow_member_invite=permission.allow_member_invite,
allow_owner_transfer=permission.allow_owner_transfer,
).model_dump(mode="json"), HTTPStatus.OK
@console_account_admission(editions=frozenset({DeploymentEdition.ENTERPRISE}))
def get(self, context: RequestContext):
permission = application_services().workspaces.management.permission(context)
return dump_response(WorkspacePermissionResponse, permission), HTTPStatus.OK
+4 -4
View File
@@ -17,9 +17,9 @@ from controllers.inner_api.plugin.wraps import get_user
from controllers.inner_api.wraps import plugin_inner_api_only
from core.plugin.entities.request import RequestDownloadFileMapping, RequestRequestUploadFile
from core.tools.signature import bind_file_uri, get_signed_file_uri_for_plugin
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.exception import BaseHTTPException
from services.account_service import TenantService
from services.file_request_service import FileRequestService
@@ -107,7 +107,7 @@ class AgentFileUploadRequestApi(Resource):
status_code=400,
) from exc
tenant = TenantService.get_tenant_by_id(payload.tenant_id, session=session)
tenant = application_services().workspaces.management.get(payload.tenant_id)
if tenant is None:
raise AgentFileRequestHttpError(
error_code="tenant_not_found",
@@ -116,7 +116,7 @@ class AgentFileUploadRequestApi(Resource):
)
try:
if payload.user_from == "account":
if not TenantService.account_belongs_to_tenant(payload.user_id, tenant.id, session=session):
if not application_services().workspaces.members.get_role(tenant.id, payload.user_id):
raise ValueError("account not found")
owner_id = payload.user_id
else:
@@ -164,7 +164,7 @@ class AgentFileDownloadRequestApi(Resource):
status_code=400,
) from exc
if TenantService.get_tenant_by_id(payload.tenant_id, session=session) is None:
if application_services().workspaces.management.get(payload.tenant_id) is None:
raise AgentFileRequestHttpError(
error_code="tenant_not_found",
description="tenant not found",
@@ -1,21 +1,19 @@
import json
from flask_restx import Resource
from pydantic import BaseModel
from sqlalchemy import select
from controllers.common.errors import InvalidArgumentError
from controllers.common.schema import register_schema_models
from controllers.console.wraps import model_validate, setup_required
from controllers.console.wraps import setup_required, validate_request
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import enterprise_inner_api_only
from events.tenant_event import tenant_was_created
from extensions.ext_database import db
from models import Account
from models.account import TenantAccountRole
from services.account_service import (
EnterpriseWorkspaceMemberAccountNotFoundError,
EnterpriseWorkspaceMemberWorkspaceNotFoundError,
TenantService,
from controllers.inner_api.wraps import inner_api_only
from enums.account import TenantAccountRole
from extensions.ext_application_services import application_services
from services.account_errors import AccountNotFoundError
from services.errors.workspace import (
InvalidWorkspaceMemberRoleError,
WorkspaceNotFoundError,
WorkspaceOwnerNotFoundError,
WorkspacesLimitExceededError,
)
@@ -43,7 +41,7 @@ register_schema_models(inner_api_ns, WorkspaceCreatePayload, WorkspaceOwnerlessP
@inner_api_ns.route("/enterprise/workspace")
class EnterpriseWorkspace(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_only
@inner_api_ns.doc("create_enterprise_workspace")
@inner_api_ns.doc(description="Create a new enterprise workspace with owner assignment")
@inner_api_ns.expect(inner_api_ns.models[WorkspaceCreatePayload.__name__])
@@ -54,38 +52,31 @@ class EnterpriseWorkspace(Resource):
404: "Owner account not found or service not available",
}
)
@model_validate(WorkspaceCreatePayload)
def post(self, args: WorkspaceCreatePayload):
account = db.session.scalar(select(Account).where(Account.email == args.owner_email).limit(1))
if account is None:
def post(self):
args = validate_request(WorkspaceCreatePayload)
try:
tenant = application_services().workspaces.provisioning.create(name=args.name, owner_email=args.owner_email)
except WorkspaceOwnerNotFoundError:
return {"message": "owner account not found."}, 404
tenant = TenantService.create_owner_tenant(
account,
name=args.name,
is_from_dashboard=True,
session=db.session(),
)
resp = {
"id": tenant.id,
"name": tenant.name,
"plan": tenant.plan,
"status": tenant.status,
"created_at": tenant.created_at.isoformat() + "Z" if tenant.created_at else None,
"updated_at": tenant.updated_at.isoformat() + "Z" if tenant.updated_at else None,
}
except WorkspacesLimitExceededError as exc:
raise InvalidArgumentError(str(exc)) from exc
return {
"message": "enterprise workspace created.",
"tenant": resp,
"tenant": {
"id": tenant.id,
"name": tenant.name,
"plan": tenant.plan,
"status": tenant.status,
"created_at": tenant.created_at.isoformat() + "Z" if tenant.created_at else None,
"updated_at": tenant.updated_at.isoformat() + "Z" if tenant.updated_at else None,
},
}
@inner_api_ns.route("/enterprise/workspace/ownerless")
class EnterpriseWorkspaceNoOwnerEmail(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_only
@inner_api_ns.doc("create_enterprise_workspace_ownerless")
@inner_api_ns.doc(description="Create a new enterprise workspace without initial owner assignment")
@inner_api_ns.expect(inner_api_ns.models[WorkspaceOwnerlessPayload.__name__])
@@ -96,33 +87,28 @@ class EnterpriseWorkspaceNoOwnerEmail(Resource):
404: "Service not available",
}
)
@model_validate(WorkspaceOwnerlessPayload)
def post(self, args: WorkspaceOwnerlessPayload):
tenant = TenantService.create_tenant(args.name, is_from_dashboard=True, session=db.session())
tenant_was_created.send(tenant)
resp = {
"id": tenant.id,
"name": tenant.name,
"encrypt_public_key": tenant.encrypt_public_key,
"plan": tenant.plan,
"status": tenant.status,
"custom_config": json.loads(tenant.custom_config) if tenant.custom_config else {},
"created_at": tenant.created_at.isoformat() + "Z" if tenant.created_at else None,
"updated_at": tenant.updated_at.isoformat() + "Z" if tenant.updated_at else None,
}
def post(self):
args = validate_request(WorkspaceOwnerlessPayload)
tenant = application_services().workspaces.provisioning.create(name=args.name)
return {
"message": "enterprise workspace created.",
"tenant": resp,
"tenant": {
"id": tenant.id,
"name": tenant.name,
"encrypt_public_key": tenant.encrypt_public_key,
"plan": tenant.plan,
"status": tenant.status,
"custom_config": tenant.custom_config,
"created_at": tenant.created_at.isoformat() + "Z" if tenant.created_at else None,
"updated_at": tenant.updated_at.isoformat() + "Z" if tenant.updated_at else None,
},
}
@inner_api_ns.route("/enterprise/workspace/member")
class EnterpriseWorkspaceMember(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_only
@inner_api_ns.doc("join_enterprise_workspace_member")
@inner_api_ns.doc(description="Add an existing account to an enterprise workspace")
@inner_api_ns.expect(inner_api_ns.models[WorkspaceMemberPayload.__name__])
@@ -134,33 +120,27 @@ class EnterpriseWorkspaceMember(Resource):
404: "Workspace or account not found",
}
)
@model_validate(WorkspaceMemberPayload)
def post(self, args: WorkspaceMemberPayload):
def post(self):
args = validate_request(WorkspaceMemberPayload)
try:
role = TenantAccountRole(args.role)
except ValueError:
return {"message": "invalid workspace member role."}, 400
if role == TenantAccountRole.OWNER:
return {"message": "cannot join workspace as owner."}, 400
try:
membership = TenantService.join_enterprise_workspace_member(
member = application_services().workspaces.provisioning.join_member(
workspace_id=args.workspace_id,
account_id=args.account_id,
email=args.email,
role=role,
role=args.role,
operator_account_id=args.operator_account_id,
)
except EnterpriseWorkspaceMemberWorkspaceNotFoundError:
except InvalidWorkspaceMemberRoleError as exc:
return {"message": str(exc)}, 400
except WorkspaceNotFoundError:
return {"message": "workspace not found."}, 404
except EnterpriseWorkspaceMemberAccountNotFoundError:
except AccountNotFoundError:
return {"message": "account not found."}, 404
return {
"message": "enterprise workspace member joined.",
"member": {
"workspace_id": membership.tenant_id,
"account_id": membership.account_id,
"role": membership.role.value,
"workspace_id": member.workspace_id,
"account_id": member.account_id,
"role": member.role,
},
}
+1 -1
View File
@@ -33,8 +33,8 @@ from machinery.context import RequestContext
from models.account import TenantAccountRole
from services.app_dsl_service import AppDslService
from services.entities.dsl_entities import AppImportParams, CheckDependenciesResult, Import, ImportStatus
from services.errors.account import NoPermissionError
from services.errors.app import WorkflowNotFoundError
from services.errors.base import NoPermissionError
def _import_response(result: Import, *, workspace_id: str) -> AppDslImportResponse:
+4 -3
View File
@@ -40,7 +40,6 @@ from extensions.ext_application_services import application_services
from models import App
from models.enums import AppStatus
from models.model import AppMode
from services.account_service import TenantService
from services.app.access import AppAccessFilter, resolve_app_access_filter
from services.entities.app_entities import AppListParams, AppSummary
@@ -184,7 +183,8 @@ class AppListApi(Resource):
str(app.id), str(app.maintainer) if app.maintainer else None, account_id
):
return empty
tenant_name = TenantService.get_tenant_name(workspace_id, session=ctx.session)
workspace = application_services().workspaces.management.get(workspace_id)
tenant_name = workspace.name if workspace else None
item = AppListRow(
id=str(app.id),
name=app.name,
@@ -216,7 +216,8 @@ class AppListApi(Resource):
tenant_name = None
if pagination.items:
tenant_name = TenantService.get_tenant_name(workspace_id, session=ctx.session)
workspace = application_services().workspaces.management.get(workspace_id)
tenant_name = workspace.name if workspace else None
items = [
AppListRow(
@@ -33,7 +33,6 @@ from controllers.openapi.auth.subjects import ExternalSsoSubject
from enums import DeploymentEdition
from extensions.ext_application_services import application_services
from models.enums import AppStatus
from services.account_service import TenantService
from services.enterprise.app_permitted_service import list_permitted_apps
from services.entities.app_entities import AppSummary
@@ -73,7 +72,7 @@ class PermittedExternalAppsListApi(Resource):
str(a.id): a for a in application_services().apps.queries.find_visible_apps_by_ids(page_result.app_ids)
}
tenant_ids = list({str(a.tenant_id) for a in apps_by_id.values()})
tenants_by_id = {str(t.id): t for t in TenantService.get_tenants_by_ids(tenant_ids, session=ctx.session)}
tenants_by_id = {str(t.id): t for t in application_services().workspaces.management.get_many(tenant_ids)}
items: list[AppListRow] = []
for app_id in page_result.app_ids:
+14
View File
@@ -17,6 +17,8 @@ from typing import TYPE_CHECKING
from sqlalchemy.orm import Session
from core.logging.context import get_request_id, get_trace_id
from machinery.context import RequestContext
from models.account import Account, Tenant, TenantAccountRole
from models.model import App, EndUser
@@ -39,11 +41,23 @@ class Context:
subject: Subject
session: Session
view_args: Mapping[str, str]
request_id: str = field(default_factory=get_request_id)
trace_id: str | None = field(default_factory=get_trace_id)
_app: App | None = field(default=None, init=False)
_workspace: Tenant | None = field(default=None, init=False)
_workspace_role: TenantAccountRole | None = field(default=None, init=False)
_caller: Caller | None = field(default=None, init=False)
@property
def request_context(self) -> RequestContext:
"""Pass only stable identity values across the application boundary."""
return RequestContext(
request_id=self.request_id,
trace_id=self.trace_id,
account_id=str(self.subject.account_id),
active_workspace_id=str(self.workspace.id),
)
@property
def app(self) -> App:
return _loaded(self._app, "app")
+4 -4
View File
@@ -7,10 +7,10 @@ from flask import request
from werkzeug.exceptions import Forbidden, NotFound
from controllers.openapi.auth.context import Caller, Context, RouteContractError
from extensions.ext_application_services import application_services
from models.account import Account, AccountStatus, Tenant, TenantAccountRole, TenantStatus
from models.enums import AppStatus
from models.model import App, EndUser
from services.account_service import TenantService
from services.app_service import AppService
@@ -106,7 +106,7 @@ def _fetch_workspace(ctx: Context) -> Tenant:
def _workspace_from_app(ctx: Context) -> Tenant:
app = load_app(ctx)
tenant = TenantService.get_tenant_by_id(app.tenant_id, session=ctx.session)
tenant = application_services().workspaces.identity.get_workspace(str(app.tenant_id))
if tenant is None or tenant.status == TenantStatus.ARCHIVE:
raise Forbidden("workspace unavailable")
return tenant
@@ -120,7 +120,7 @@ def _workspace_from_request(ctx: Context) -> Tenant:
uuid.UUID(workspace_id)
except ValueError:
raise NotFound("workspace not found")
tenant = TenantService.get_tenant_by_id(workspace_id, session=ctx.session)
tenant = application_services().workspaces.identity.get_workspace(workspace_id)
if tenant is None or tenant.status == TenantStatus.ARCHIVE:
raise NotFound("workspace not found")
return tenant
@@ -131,7 +131,7 @@ def _fetch_workspace_role(ctx: Context) -> TenantAccountRole:
caller = load_caller(ctx)
if not isinstance(caller, Account) or caller.status != AccountStatus.ACTIVE:
raise NotFound("workspace not found")
role = TenantService.get_account_role_in_tenant(str(ctx.subject.account_id), workspace.id, session=ctx.session)
role = application_services().workspaces.members.get_role(str(workspace.id), str(ctx.subject.account_id))
if role is None:
raise NotFound("workspace not found")
return role
@@ -21,14 +21,17 @@ from configs import dify_config
from constants.oauth_bearer import Scope
from controllers.common.rbac import RBACCheck, enforce_rbac_checks
from controllers.openapi._audit import emit_wrong_surface
from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded
from controllers.openapi.auth.context import Context
from controllers.openapi.auth.loaders import load_app, load_caller, load_workspace, load_workspace_role
from controllers.openapi.auth.subjects import Subject
from enums import DeploymentEdition
from extensions.ext_application_services import application_services
from models.account import TenantAccountRole
from models.enums import CreatorUserRole
from services.enterprise.enterprise_service import EnterpriseService, WebAppAccessMode
from services.entities.feature_entities import LicenseStatus
from services.errors.workspace import WorkspaceInvitationQuotaError, WorkspaceMemberLicenseQuotaError
from services.system_feature_service import SystemFeatureService
_DEAD_LICENSE_STATUSES = frozenset({LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST})
@@ -116,6 +119,19 @@ class CheckScope(Requirement):
raise Forbidden("insufficient_scope")
class CheckWorkspaceInvitationQuota(Requirement):
"""Run after workspace membership and permission checks, before the handler."""
@override
def run(self, subject: Subject, ctx: Context, session: Session) -> None:
try:
application_services().workspaces.invitations.check_invitation_quota(ctx.request_context)
except WorkspaceMemberLicenseQuotaError as error:
raise MemberLicenseExceeded() from error
except WorkspaceInvitationQuotaError as error:
raise MemberLimitExceeded() from error
class CheckRBACPermission(Requirement):
"""The same check bundles the console's `rbac_permission_required` takes.
Inert wherever RBAC is off; a route that needs a check there declares a
+7 -1
View File
@@ -14,6 +14,7 @@ from controllers.openapi.auth.requirements import assert_license_valid
from controllers.openapi.auth.spec import EndpointSpec
from controllers.openapi.auth.subjects import subject_from_auth
from core.db.session_factory import session_factory
from core.logging.context import get_trace_id
from enums import DeploymentEdition
from libs.oauth_bearer import InvalidBearerError, assert_bearer_feature_enabled, extract_bearer, get_authenticator
@@ -61,7 +62,12 @@ class AuthRouter:
# Account-context endpoints materialize identity and release it in the
# pipeline, before calling services that own their transactions.
with session_factory.create_session() as session:
ctx = Context(subject, session, dict(request.view_args or {}))
ctx = Context(
subject,
session,
dict(request.view_args or {}),
trace_id=get_trace_id() or request.headers.get("X-Trace-Id"),
)
try:
result = pipeline.run(
subject=subject,
+2 -3
View File
@@ -21,7 +21,6 @@ from libs.oauth_bearer import AuthContext
from models.account import Account
from models.enums import CreatorUserRole, EndUserType
from models.model import EndUser
from services.account_service import AccountService
from services.enterprise.enterprise_service import WebAppAccessMode
_SUBJECT_CLASSES: dict[SubjectType, type[Subject]] = {}
@@ -87,7 +86,7 @@ class AccountSubject(Subject):
@override
def resolve_caller(self, ctx: Context, session: Session) -> Account:
account = AccountService.get_account_by_id(str(self.account_id), session=session)
account = application_services().accounts.identity.get_account_by_id(str(self.account_id))
if account is None:
raise Unauthorized("account not found")
if ctx._workspace is not None:
@@ -143,7 +142,7 @@ class ExternalSsoSubject(Subject):
identity = self.external_identity
if identity is None:
return None
account = AccountService.get_account_by_email(identity.email, session=session)
account = application_services().accounts.identity.get_account_by_email(identity.email)
return account.id if account is not None else None
+75 -81
View File
@@ -12,8 +12,8 @@ account bearer and lets the view's own membership-scoped lookup answer 404.
from __future__ import annotations
from itertools import starmap
from urllib import parse
from uuid import UUID
from flask_restx import Resource
from werkzeug.exceptions import BadRequest, NotFound
@@ -23,7 +23,6 @@ from constants.oauth_bearer import Scope
from controllers.common.rbac import RBACCheck, RBACPermission, Workspace
from controllers.openapi import openapi_ns
from controllers.openapi._contract import Example, Kind, endpoint
from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded
from controllers.openapi._models import (
MemberActionResponse,
MemberInvitePayload,
@@ -42,50 +41,37 @@ from controllers.openapi.auth.requirements import (
CheckRBACPermission,
CheckScope,
CheckSubject,
CheckWorkspaceInvitationQuota,
CheckWorkspaceMember,
CheckWorkspaceRole,
)
from controllers.openapi.auth.subjects import AccountSubject
from enums import DeploymentEdition
from models import Account, Tenant, TenantAccountJoin
from models.account import TenantAccountRole
from services.account_service import AccountService, RegisterService, TenantService
from services.errors.account import (
from enums.account import TenantAccountRole
from extensions.ext_application_services import application_services
from services.account_errors import AccountNotFoundError, AccountRegisterError, SeatsLimitExceededError
from services.errors.base import NoPermissionError
from services.errors.workspace import (
AccountAlreadyInTenantError,
AccountNotLinkTenantError,
AccountRegisterError,
CannotOperateSelfError,
InvalidWorkspaceMemberRoleError,
MemberNotInTenantError,
NoPermissionError,
RoleAlreadyAssignedError,
SeatsLimitExceededError,
WorkspaceNotLinkedError,
)
from services.feature_service import FeatureService
from services.workspace.contracts import WorkspaceInvitation, WorkspaceMemberRecord, WorkspaceSnapshot
def _member_response(account: Account) -> MemberResponse:
def _member_response(account: WorkspaceMemberRecord) -> MemberResponse:
return MemberResponse(
id=account.id,
name=account.name,
email=account.email,
role=account.role.value if account.role else "",
status=account.status.value if account.status else "",
role=account.legacy_role,
status=account.status,
avatar=account.avatar,
)
def _check_member_invite_quota(tenant_id: str) -> None:
features = FeatureService.get_features(tenant_id)
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
members = features.members
if 0 < members.limit <= members.size:
raise MemberLimitExceeded()
if features.workspace_members.enabled and not features.workspace_members.is_available(1):
raise MemberLicenseExceeded()
@openapi_ns.route("/workspaces")
class WorkspacesApi(Resource):
@endpoint(
@@ -98,8 +84,8 @@ class WorkspacesApi(Resource):
returns=(200, WorkspaceListResponse, "Workspace list"),
)
def get(self, ctx: Context, *, query: WorkspaceListQuery):
rows = TenantService.get_workspaces_for_account(str(ctx.subject.account_id), session=ctx.session)
return WorkspaceListResponse.page_of(list(starmap(_workspace_summary, rows)), query=query)
rows = application_services().workspaces.management.list_memberships(str(ctx.subject.account_id))
return WorkspaceListResponse.page_of([_workspace_summary(row) for row in rows], query=query)
@openapi_ns.route("/workspaces/<string:workspace_id>")
@@ -116,12 +102,11 @@ class WorkspaceByIdApi(Resource):
returns=(200, WorkspaceDetailResponse, "Workspace detail"),
)
def get(self, ctx: Context, workspace_id: str):
row = TenantService.find_workspace_for_account(str(ctx.subject.account_id), workspace_id, session=ctx.session)
row = application_services().workspaces.management.find_membership(str(ctx.subject.account_id), workspace_id)
if row is None:
raise NotFound("workspace not found")
tenant, membership = row
return _workspace_detail(tenant, membership)
return _workspace_detail(row)
@openapi_ns.route("/workspaces/<string:workspace_id>:switch")
@@ -148,15 +133,12 @@ class WorkspaceSwitchApi(Resource):
)
def post(self, ctx: Context, workspace_id: str):
try:
TenantService.switch_tenant(ctx.account, workspace_id, session=ctx.session)
except AccountNotLinkTenantError:
raise NotFound("workspace not found")
row = TenantService.find_workspace_for_account(str(ctx.subject.account_id), workspace_id, session=ctx.session)
if row is None:
raise NotFound("workspace not found")
tenant, membership = row
return _workspace_detail(tenant, membership)
workspace = application_services().workspaces.management.switch_membership(
str(ctx.subject.account_id), workspace_id
)
except WorkspaceNotLinkedError:
raise NotFound("workspace not found") from None
return _workspace_detail(workspace)
@openapi_ns.route("/workspaces/<string:workspace_id>/members")
@@ -181,8 +163,15 @@ class WorkspaceMembersApi(Resource):
returns=(200, MemberListResponse, "Member list"),
)
def get(self, ctx: Context, workspace_id: str, *, query: MemberListQuery):
members = TenantService.get_tenant_members(ctx.workspace, session=ctx.session)
return MemberListResponse.page_of([_member_response(m) for m in members], query=query)
result = application_services().workspaces.member_queries.list_page(
ctx.request_context, page=query.page, limit=query.limit
)
return MemberListResponse.build(
page=query.page,
limit=query.limit,
total=result.total,
items=[_member_response(m) for m in result.members],
)
@endpoint(
op="invite.workspace.member",
@@ -198,26 +187,23 @@ class WorkspaceMembersApi(Resource):
CheckWorkspaceMember(),
CheckRBACPermission(RBACCheck(RBACPermission.WORKSPACE_MEMBER_MANAGE, Workspace())),
CheckWorkspaceRole(frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})),
CheckWorkspaceInvitationQuota(),
),
body=MemberInvitePayload,
returns=(201, MemberInviteResponse, "Member invited"),
)
def post(self, ctx: Context, workspace_id: str, *, body: MemberInvitePayload):
tenant = ctx.workspace
_check_member_invite_quota(tenant.id)
try:
token = RegisterService.invite_new_member(
tenant=tenant,
invitation = application_services().workspaces.invitations.invite(
ctx.request_context,
email=body.email,
language=None,
role=body.role,
inviter=ctx.account,
session=ctx.session,
)
except AccountAlreadyInTenantError as exc:
raise BadRequest(str(exc))
except InvalidWorkspaceMemberRoleError as exc:
raise BadRequest(str(exc)) from exc
except NoPermissionError as exc:
raise BadRequest(str(exc))
except SeatsLimitExceededError:
@@ -225,20 +211,7 @@ class WorkspaceMembersApi(Resource):
except AccountRegisterError as exc:
raise BadRequest(str(exc))
normalized_email = body.email.lower()
member = AccountService.get_account_by_email_with_case_fallback(normalized_email, session=ctx.session)
if member is None:
raise RuntimeError("invited member missing from DB after invite")
encoded_email = parse.quote(normalized_email)
invite_url = f"{dify_config.CONSOLE_WEB_URL}/activate?email={encoded_email}&token={token}"
return MemberInviteResponse(
email=normalized_email,
role=body.role,
member_id=member.id,
invite_url=invite_url,
tenant_id=tenant.id,
)
return _invitation_response(invitation)
@openapi_ns.route("/workspaces/<string:workspace_id>/members/<string:member_id>")
@@ -266,12 +239,11 @@ class WorkspaceMemberApi(Resource):
returns=(200, MemberActionResponse, "Member removed"),
)
def delete(self, ctx: Context, workspace_id: str, member_id: str):
member = AccountService.get_account_by_id(member_id, session=ctx.session)
if member is None:
raise NotFound("member not found")
member_id = _parse_member_id(member_id)
try:
TenantService.remove_member_from_tenant(ctx.workspace, member, ctx.account, session=ctx.session)
application_services().workspaces.members.remove(workspace_id, member_id, str(ctx.subject.account_id))
except AccountNotFoundError:
raise NotFound("member not found") from None
except CannotOperateSelfError as exc:
raise BadRequest(str(exc))
except NoPermissionError as exc:
@@ -300,12 +272,13 @@ class WorkspaceMemberApi(Resource):
returns=(200, MemberActionResponse, "Role updated"),
)
def patch(self, ctx: Context, workspace_id: str, member_id: str, *, body: MemberRoleUpdatePayload):
member = AccountService.get_account_by_id(member_id, session=ctx.session)
if member is None:
raise NotFound("member not found")
member_id = _parse_member_id(member_id)
try:
TenantService.update_member_role(ctx.workspace, member, body.role, ctx.account, session=ctx.session)
application_services().workspaces.members.update_role(
workspace_id, member_id, body.role, str(ctx.subject.account_id)
)
except AccountNotFoundError:
raise NotFound("member not found") from None
except CannotOperateSelfError as exc:
raise BadRequest(str(exc))
except NoPermissionError as exc:
@@ -314,26 +287,47 @@ class WorkspaceMemberApi(Resource):
raise NotFound(str(exc))
except RoleAlreadyAssignedError as exc:
raise BadRequest(str(exc))
except InvalidWorkspaceMemberRoleError as exc:
raise BadRequest(str(exc)) from exc
return MemberActionResponse()
def _workspace_summary(tenant: Tenant, membership: TenantAccountJoin) -> WorkspaceSummaryResponse:
def _parse_member_id(member_id: str) -> str:
# Permission comparisons must use the same identity as UUID database lookups.
try:
return str(UUID(member_id))
except ValueError:
raise NotFound("member not found") from None
def _invitation_response(invitation: WorkspaceInvitation) -> MemberInviteResponse:
encoded_email = parse.quote(invitation.email)
return MemberInviteResponse(
email=invitation.email,
role=invitation.role,
member_id=invitation.account_id,
invite_url=f"{dify_config.CONSOLE_WEB_URL}/activate?email={encoded_email}&token={invitation.token}",
tenant_id=invitation.workspace_id,
)
def _workspace_summary(tenant: WorkspaceSnapshot) -> WorkspaceSummaryResponse:
return WorkspaceSummaryResponse(
id=tenant.id,
name=tenant.name,
role=getattr(membership, "role", ""),
role=tenant.role or "",
status=tenant.status,
current=getattr(membership, "current", False),
current=tenant.current,
)
def _workspace_detail(tenant: Tenant, membership: TenantAccountJoin) -> WorkspaceDetailResponse:
def _workspace_detail(tenant: WorkspaceSnapshot) -> WorkspaceDetailResponse:
return WorkspaceDetailResponse(
id=tenant.id,
name=tenant.name,
role=getattr(membership, "role", ""),
role=tenant.role or "",
status=tenant.status,
current=getattr(membership, "current", False),
current=tenant.current,
created_at=tenant.created_at.isoformat() if tenant.created_at else None,
)
+2
View File
@@ -1,6 +1,7 @@
from flask import Blueprint
from flask_restx import Namespace
from controllers.common.errors import register_permission_error_handler
from libs.external_api import ExternalApi
bp = Blueprint("service_api", __name__, url_prefix="/v1")
@@ -12,6 +13,7 @@ api = ExternalApi(
description="API for application services",
register_default_root=False,
)
register_permission_error_handler(api)
service_api_ns = Namespace("service_api", description="Service operations", path="/")
@@ -16,7 +16,7 @@ from pydantic import (
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound
import services
import services.errors.base
from configs import dify_config
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import (
@@ -607,7 +607,7 @@ class DatasetApi(DatasetApiResource):
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
except services.errors.base.NoPermissionError as e:
raise Forbidden(str(e))
data = _dump_service_dataset_detail(load_dataset_detail(dataset, session=session))
# check embedding setting
@@ -720,7 +720,6 @@ class DatasetApi(DatasetApiResource):
payload.partial_member_list,
session=session,
)
dataset = DatasetService.update_dataset(dataset_id_str, update_data, current_user, session=session)
if dataset is None:
@@ -865,7 +864,7 @@ class DocumentStatusApi(DatasetApiResource):
# Check user's permission
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
except services.errors.base.NoPermissionError as e:
raise Forbidden(str(e))
# Check dataset model setting
@@ -36,7 +36,6 @@ from extensions.ext_database import db
from extensions.ext_redis import redis_client
from models.enums import MessageFileBelongsTo
from models.model import App, AppMode, Conversation, MessageAnnotation, MessageFile
from services.account_service import AccountService
from services.annotation_service import AppAnnotationService
logger = logging.getLogger(__name__)
@@ -166,14 +165,14 @@ class MessageCycleManager:
:param event: event
:return:
"""
annotation = AppAnnotationService.get_annotation_by_id(event.message_annotation_id, session)
if annotation:
account = AccountService.get_account_by_id(annotation.account_id, session=session)
reply = AppAnnotationService.get_annotation_reply_by_id(event.message_annotation_id, session)
if reply:
annotation, account_name = reply
self._task_state.metadata.annotation_reply = AnnotationReply(
id=annotation.id,
account=AnnotationReplyAccount(
id=annotation.account_id,
name=account.name if account else "Dify user",
name=account_name if account_name is not None else "Dify user",
),
)
@@ -10,20 +10,42 @@ from sqlalchemy import delete, func, select, update
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
from core.rag.index_processor.constant.index_type import IndexTechniqueType
from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
from core.rag.index_processor.index_processor_base import BaseIndexProcessor, SummaryIndexSettingDict
from core.workflow.nodes.knowledge_index.exc import KnowledgeIndexNodeError
from core.workflow.nodes.knowledge_index.protocols import IndexingResultDict, Preview, PreviewItem, QaPreview
from models.dataset import Dataset, Document, DocumentSegment
from models.enums import SegmentStatus
from services.vector_space_admission_service import VectorSpaceAdmissionService
from .index_processor_factory import IndexProcessorFactory
from .processor.paragraph_index_processor import ParagraphIndexProcessor
from .processor.parent_child_index_processor import ParentChildIndexProcessor
from .processor.qa_index_processor import QAIndexProcessor
logger = logging.getLogger(__name__)
class IndexProcessorFactory:
"""Select the processor for a document's index structure."""
def __init__(self, index_type: str | None):
self._index_type = index_type
def init_index_processor(self) -> BaseIndexProcessor:
"""Create a processor for a supported index type."""
if not self._index_type:
raise ValueError("Index type must be specified.")
if self._index_type == IndexStructureType.PARAGRAPH_INDEX:
return ParagraphIndexProcessor()
elif self._index_type == IndexStructureType.QA_INDEX:
return QAIndexProcessor()
elif self._index_type == IndexStructureType.PARENT_CHILD_INDEX:
return ParentChildIndexProcessor()
else:
raise ValueError(f"Index type {self._index_type} is not supported.")
class IndexProcessor:
def format_preview(self, chunk_structure: str, chunks: Any) -> Preview:
index_processor = IndexProcessorFactory(chunk_structure).init_index_processor()
@@ -1,29 +0,0 @@
"""Abstract interface for document loader implementations."""
from core.rag.index_processor.constant.index_type import IndexStructureType
from core.rag.index_processor.index_processor_base import BaseIndexProcessor
from core.rag.index_processor.processor.paragraph_index_processor import ParagraphIndexProcessor
from core.rag.index_processor.processor.parent_child_index_processor import ParentChildIndexProcessor
from core.rag.index_processor.processor.qa_index_processor import QAIndexProcessor
class IndexProcessorFactory:
"""IndexProcessorInit."""
def __init__(self, index_type: str | None):
self._index_type = index_type
def init_index_processor(self) -> BaseIndexProcessor:
"""Init index processor."""
if not self._index_type:
raise ValueError("Index type must be specified.")
if self._index_type == IndexStructureType.PARAGRAPH_INDEX:
return ParagraphIndexProcessor()
elif self._index_type == IndexStructureType.QA_INDEX:
return QAIndexProcessor()
elif self._index_type == IndexStructureType.PARENT_CHILD_INDEX:
return ParentChildIndexProcessor()
else:
raise ValueError(f"Index type {self._index_type} is not supported.")
@@ -30,6 +30,7 @@ from core.rag.index_processor.index_processor_base import BaseIndexProcessor, Su
from core.rag.models.document import AttachmentDocument, Document, MultimodalGeneralStructureChunk
from core.tools.utils.text_processing_utils import remove_leading_symbols
from core.workflow.file_reference import build_file_reference
from extensions.ext_login import load_account
from factories.file_factory import build_from_mapping
from graphon.file import File, FileTransferMethod, FileType, file_manager
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
@@ -46,7 +47,6 @@ from models import UploadFile
from models.account import Account
from models.dataset import Dataset, DatasetProcessRule, DocumentSegment, SegmentAttachmentBinding
from models.dataset import Document as DatasetDocument
from services.account_service import AccountService
from services.knowledge.summaries.adapters import SummaryIndexAdapter
logger = logging.getLogger(__name__)
@@ -242,8 +242,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
all_multimodal_documents.append(file_document)
doc.attachments = attachments
else:
with session_factory.create_session() as account_session:
account = AccountService.load_user(document.created_by, account_session)
account = load_account(document.created_by)
if not account:
raise ValueError("Invalid account")
doc.attachments = self._get_content_files(
@@ -23,12 +23,12 @@ from core.rag.index_processor.constant.doc_type import DocType
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
from core.rag.index_processor.index_processor_base import BaseIndexProcessor, SummaryIndexSettingDict
from core.rag.models.document import AttachmentDocument, ChildDocument, Document, ParentChildStructureChunk
from extensions.ext_login import load_account
from libs import helper
from models import Account
from models.dataset import ChildChunk, Dataset, DatasetProcessRule, DocumentSegment
from models.dataset import Document as DatasetDocument
from models.enums import ProcessRuleMode
from services.account_service import AccountService
from services.knowledge.summaries.adapters import SummaryIndexAdapter
logger = logging.getLogger(__name__)
@@ -307,8 +307,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
attachments.append(file_document)
doc.attachments = attachments
else:
with session_factory.create_session() as account_session:
account = AccountService.load_user(document.created_by, account_session)
account = load_account(document.created_by)
if not account:
raise ValueError("Invalid account")
doc.attachments = self._get_content_files(
+64
View File
@@ -0,0 +1,64 @@
"""Shared workspace membership roles, independent of persistence models."""
from enum import StrEnum
from typing import Optional
class TenantAccountRole(StrEnum):
OWNER = "owner"
ADMIN = "admin"
EDITOR = "editor"
NORMAL = "normal"
DATASET_OPERATOR = "dataset_operator"
@staticmethod
def is_valid_role(role: str) -> bool:
if not role:
return False
return role in {
TenantAccountRole.OWNER,
TenantAccountRole.ADMIN,
TenantAccountRole.EDITOR,
TenantAccountRole.NORMAL,
TenantAccountRole.DATASET_OPERATOR,
}
@staticmethod
def is_privileged_role(role: Optional["TenantAccountRole"]) -> bool:
if not role:
return False
return role in {TenantAccountRole.OWNER, TenantAccountRole.ADMIN}
@staticmethod
def is_admin_role(role: Optional["TenantAccountRole"]) -> bool:
if not role:
return False
return role == TenantAccountRole.ADMIN
@staticmethod
def is_non_owner_role(role: Optional["TenantAccountRole"]) -> bool:
if not role:
return False
return role in {
TenantAccountRole.ADMIN,
TenantAccountRole.EDITOR,
TenantAccountRole.NORMAL,
TenantAccountRole.DATASET_OPERATOR,
}
@staticmethod
def is_editing_role(role: Optional["TenantAccountRole"]) -> bool:
if not role:
return False
return role in {TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR}
@staticmethod
def is_dataset_edit_role(role: Optional["TenantAccountRole"]) -> bool:
if not role:
return False
return role in {
TenantAccountRole.OWNER,
TenantAccountRole.ADMIN,
TenantAccountRole.EDITOR,
TenantAccountRole.DATASET_OPERATOR,
}
@@ -0,0 +1,358 @@
"""Compose the account domain using shared account and workspace persistence."""
from dataclasses import dataclass
from functools import partial
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from constants.languages import languages
from enums import DeploymentEdition
from extensions.ext_redis import RedisClientWrapper
from libs.datetime_utils import naive_utc_now, utc_now
from libs.helper import RateLimiter
from libs.oauth import GitHubOAuth, GoogleOAuth
from libs.oauth_bearer import invalidate_oauth_token_cache
from repositories.account.repository import SQLAlchemyAccountRepository
from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository
from repositories.account_integration_repository import SQLAlchemyAccountIntegrationRepository
from repositories.oauth_access_token_repository import SQLAlchemyOAuthAccessTokenRepository
from repositories.workspace.workspace_repository import WorkspaceRepository
from services.account.adapters import (
AccountIdentityGateway,
BillingAccountActivationEligibility,
BillingAccountDeletionFeedbackGateway,
BillingAccountEducationGateway,
BillingAccountEmailPolicyGateway,
BillingWorkspaceMembershipCache,
CeleryAccountDeletionScheduler,
CeleryAccountDeletionVerificationNotifier,
CeleryChangeEmailNotificationGateway,
DeploymentAccountLifecyclePolicy,
DeploymentWorkspaceInvitePolicy,
EnterpriseAccountDeletionSyncGateway,
RateLimiterChangeEmailSendLimiter,
RBACWorkspaceMemberAccessSync,
RedisChangeEmailSecurityGateway,
RedisInvitationTokenStore,
SecureChangeEmailCodeGenerator,
TokenManagerAccountDeletionVerificationGateway,
TokenManagerChangeEmailTokenGateway,
)
from services.account.email_registration_adapters import (
AccountLifecycleRegistrationGateway,
BillingAccountRegistrationPolicyGateway,
CeleryEmailRegistrationNotificationGateway,
RateLimiterEmailRegistrationSendLimiter,
RedisEmailRegistrationSecurityGateway,
SecureEmailRegistrationCodeGenerator,
TokenManagerEmailRegistrationTokenGateway,
)
from services.account.email_registration_service import AccountEmailRegistrationService
from services.account.forgot_password_adapters import (
CeleryForgotPasswordNotificationGateway,
RateLimiterForgotPasswordSendLimiter,
RedisForgotPasswordSecurityGateway,
RedisForgotPasswordTokenGateway,
SecureForgotPasswordCodeGenerator,
SystemFeatureServiceForgotPasswordRegistrationPolicy,
)
from services.account.forgot_password_service import AccountForgotPasswordService
from services.account.login_adapters import (
AccountActivationConsoleAuthInvitationGateway,
DeploymentConsoleAuthPolicyGateway,
LoggingConsoleAuthAuditGateway,
RedisAccountSessionGateway,
RedisConsoleAuthSecurityGateway,
RedisEmailCodeGateway,
RedisResetPasswordEmailGateway,
SQLAlchemyAccountRefreshPreparationGateway,
TurnstileHumanVerificationGateway,
)
from services.account.login_service import ConsoleAuthenticationService
from services.account.oauth_adapters import (
AccountActivationOAuthInvitationGateway,
AccountLifecycleOAuthRegistrationGateway,
AccountLifecycleOAuthSessionGateway,
DeploymentOAuthPolicyGateway,
DifyOAuthProviderGateway,
RedisOAuthAccountClaimLock,
WorkspaceProvisioningOAuthGateway,
)
from services.account.oauth_service import AccountOAuthService, OAuthProviderGateway
from services.account.service import AccountService
from services.account_access_service import AccountAccessService
from services.account_activation_service import (
AccountActivationRepository,
AccountActivationService,
InvitationTokenStore,
)
from services.account_avatar_file_gateway import SQLAlchemyAccountAvatarFileGateway
from services.account_avatar_service import AccountAvatarService
from services.account_change_email_service import AccountChangeEmailService
from services.account_deletion_feedback_service import AccountDeletionFeedbackService
from services.account_deletion_service import AccountDeletionService
from services.account_education_service import AccountEducationService
from services.account_initialization_service import AccountInitializationService
from services.account_integration_service import AccountIntegrationService
from services.account_password_hasher import DefaultAccountPasswordHasher
from services.account_password_service import AccountPasswordService
from services.account_profile_service import AccountProfileService
from services.workspace.provisioning_service import WorkspaceProvisioningService
@dataclass(frozen=True, slots=True)
class AccountServices:
access: AccountAccessService
activation: AccountActivationService
authentication: ConsoleAuthenticationService
avatar: AccountAvatarService
change_email: AccountChangeEmailService
email_registration: AccountEmailRegistrationService
deletion: AccountDeletionService
deletion_feedback: AccountDeletionFeedbackService
education: AccountEducationService
forgot_password: AccountForgotPasswordService
initialization: AccountInitializationService
integrations: AccountIntegrationService
oauth: AccountOAuthService
password: AccountPasswordService
profile: AccountProfileService
lifecycle: AccountService
identity: AccountIdentityGateway
def _build_account_oauth_service(
*,
deployment_edition: DeploymentEdition,
redis: RedisClientWrapper,
accounts: SQLAlchemyAccountRepository,
integrations: SQLAlchemyAccountIntegrationRepository,
memberships: WorkspaceRepository,
lifecycle: AccountService,
workspace_provisioning: WorkspaceProvisioningService,
invitation_tokens: InvitationTokenStore,
activation_accounts: AccountActivationRepository,
) -> AccountOAuthService:
providers: dict[str, OAuthProviderGateway] = {}
if dify_config.GITHUB_CLIENT_ID and dify_config.GITHUB_CLIENT_SECRET:
providers["github"] = DifyOAuthProviderGateway(
provider_name="github",
client=GitHubOAuth(
client_id=dify_config.GITHUB_CLIENT_ID,
client_secret=dify_config.GITHUB_CLIENT_SECRET,
redirect_uri=dify_config.CONSOLE_API_URL + "/console/api/oauth/authorize/github",
),
)
if dify_config.GOOGLE_CLIENT_ID and dify_config.GOOGLE_CLIENT_SECRET:
providers["google"] = DifyOAuthProviderGateway(
provider_name="google",
client=GoogleOAuth(
client_id=dify_config.GOOGLE_CLIENT_ID,
client_secret=dify_config.GOOGLE_CLIENT_SECRET,
redirect_uri=dify_config.CONSOLE_API_URL + "/console/api/oauth/authorize/google",
),
)
policy = DeploymentOAuthPolicyGateway(
billing_enabled=deployment_edition == DeploymentEdition.CLOUD,
)
return AccountOAuthService(
providers=providers,
accounts=accounts,
integrations=integrations,
memberships=memberships,
invitations=AccountActivationOAuthInvitationGateway(tokens=invitation_tokens, accounts=activation_accounts),
account_claims=RedisOAuthAccountClaimLock(client=redis),
registration=AccountLifecycleOAuthRegistrationGateway(accounts=lifecycle),
workspaces=WorkspaceProvisioningOAuthGateway(workspaces=workspace_provisioning),
sessions=AccountLifecycleOAuthSessionGateway(accounts=lifecycle),
registration_policy=policy,
workspace_policy=policy,
supported_languages=languages,
now=naive_utc_now,
)
def build_account_services(
*,
database_client: sessionmaker[Session],
deployment_edition: DeploymentEdition,
redis: RedisClientWrapper,
accounts: SQLAlchemyAccountRepository,
integrations: SQLAlchemyAccountIntegrationRepository,
workspace_repository: WorkspaceRepository,
workspace_provisioning: WorkspaceProvisioningService,
passwords: DefaultAccountPasswordHasher,
invitation_tokens: RedisInvitationTokenStore,
activation_accounts: SQLAlchemyAccountActivationRepository,
) -> AccountServices:
lifecycle = AccountService(
security=RedisConsoleAuthSecurityGateway(redis=redis),
accounts=accounts,
policies=DeploymentAccountLifecyclePolicy(),
passwords=passwords,
workspaces=workspace_provisioning,
sessions=RedisAccountSessionGateway(redis=redis),
now=naive_utc_now,
)
return AccountServices(
lifecycle=lifecycle,
activation=AccountActivationService(
tokens=invitation_tokens,
accounts=activation_accounts,
workspace_policy=DeploymentWorkspaceInvitePolicy(),
eligibility=BillingAccountActivationEligibility(
enabled=deployment_edition == DeploymentEdition.CLOUD,
),
membership_cache=BillingWorkspaceMembershipCache(
enabled=deployment_edition == DeploymentEdition.CLOUD,
),
member_access_sync=RBACWorkspaceMemberAccessSync(
enabled=dify_config.RBAC_ENABLED,
),
),
identity=AccountIdentityGateway(accounts=accounts, redis=redis),
access=AccountAccessService(
accounts=accounts,
workspaces=workspace_repository,
sessions=SQLAlchemyOAuthAccessTokenRepository(session_factory=database_client),
invalidate_token_cache=partial(invalidate_oauth_token_cache, redis),
now=utc_now,
),
authentication=ConsoleAuthenticationService(
accounts=accounts,
workspaces=workspace_repository,
invitations=AccountActivationConsoleAuthInvitationGateway(
tokens=invitation_tokens,
accounts=activation_accounts,
),
policies=DeploymentConsoleAuthPolicyGateway(
billing_enabled=deployment_edition == DeploymentEdition.CLOUD,
),
security=RedisConsoleAuthSecurityGateway(redis=redis),
passwords=passwords,
human_verification=TurnstileHumanVerificationGateway(),
sessions=RedisAccountSessionGateway(redis=redis),
refresh_preparation=SQLAlchemyAccountRefreshPreparationGateway(accounts=accounts),
account_provisioning=workspace_provisioning,
workspace_provisioning=workspace_provisioning,
email_codes=RedisEmailCodeGateway(redis=redis),
reset_password_emails=RedisResetPasswordEmailGateway(redis=redis),
audit=LoggingConsoleAuthAuditGateway(),
now=naive_utc_now,
turnstile_enabled=deployment_edition == DeploymentEdition.CLOUD,
turnstile_verify_required=(
deployment_edition == DeploymentEdition.CLOUD and dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED
),
),
avatar=AccountAvatarService(
files=SQLAlchemyAccountAvatarFileGateway(session_factory=database_client),
),
change_email=AccountChangeEmailService(
accounts=accounts,
tokens=TokenManagerChangeEmailTokenGateway(),
codes=SecureChangeEmailCodeGenerator(),
notifications=CeleryChangeEmailNotificationGateway(),
send_limits=RateLimiterChangeEmailSendLimiter(redis=redis),
security=RedisChangeEmailSecurityGateway(
redis=redis,
email_send_ip_limit_per_minute=dify_config.EMAIL_SEND_IP_LIMIT_PER_MINUTE,
verification_failure_limit=5,
verification_lockout_duration=dify_config.CHANGE_EMAIL_LOCKOUT_DURATION,
),
email_policy=BillingAccountEmailPolicyGateway(
billing_enabled=deployment_edition == DeploymentEdition.CLOUD,
),
),
email_registration=AccountEmailRegistrationService(
accounts=accounts,
tokens=TokenManagerEmailRegistrationTokenGateway(),
codes=SecureEmailRegistrationCodeGenerator(),
notifications=CeleryEmailRegistrationNotificationGateway(),
send_limits=RateLimiterEmailRegistrationSendLimiter(
rate_limiter=RateLimiter(
prefix="email_register_rate_limit",
max_attempts=1,
time_window=60,
redis_client=redis,
)
),
security=RedisEmailRegistrationSecurityGateway(
redis=redis,
login_security=RedisConsoleAuthSecurityGateway(redis=redis),
verification_failure_limit=5,
verification_lockout_duration=dify_config.EMAIL_REGISTER_LOCKOUT_DURATION,
),
account_policy=BillingAccountRegistrationPolicyGateway(
enabled=deployment_edition == DeploymentEdition.CLOUD,
),
registration=AccountLifecycleRegistrationGateway(accounts=lifecycle),
),
deletion=AccountDeletionService(
accounts=accounts,
memberships=workspace_repository,
verification=TokenManagerAccountDeletionVerificationGateway(),
notifications=CeleryAccountDeletionVerificationNotifier(redis=redis),
synchronization=EnterpriseAccountDeletionSyncGateway(),
scheduler=CeleryAccountDeletionScheduler(),
),
deletion_feedback=AccountDeletionFeedbackService(
feedback=BillingAccountDeletionFeedbackGateway(),
),
education=AccountEducationService(
accounts=accounts,
education=BillingAccountEducationGateway(),
verification_rate_limiter=RateLimiter(
prefix="edu_verification_rate_limit",
max_attempts=10,
time_window=60,
redis_client=redis,
),
activation_rate_limiter=RateLimiter(
prefix="edu_activation_rate_limit",
max_attempts=10,
time_window=60,
redis_client=redis,
),
),
forgot_password=AccountForgotPasswordService(
accounts=accounts,
passwords=passwords,
tokens=RedisForgotPasswordTokenGateway(
redis=redis,
expiry_seconds=dify_config.RESET_PASSWORD_TOKEN_EXPIRY_MINUTES * 60,
),
codes=SecureForgotPasswordCodeGenerator(),
notifications=CeleryForgotPasswordNotificationGateway(),
send_limits=RateLimiterForgotPasswordSendLimiter(redis=redis),
security=RedisForgotPasswordSecurityGateway(
redis=redis,
email_send_ip_limit_per_minute=dify_config.EMAIL_SEND_IP_LIMIT_PER_MINUTE,
verification_lockout_duration=dify_config.FORGOT_PASSWORD_LOCKOUT_DURATION,
),
registration=SystemFeatureServiceForgotPasswordRegistrationPolicy(),
),
initialization=AccountInitializationService(
accounts=accounts,
invitation_required=deployment_edition == DeploymentEdition.CLOUD,
now=naive_utc_now,
),
integrations=AccountIntegrationService(integrations=integrations),
oauth=_build_account_oauth_service(
deployment_edition=deployment_edition,
redis=redis,
accounts=accounts,
integrations=integrations,
memberships=workspace_repository,
lifecycle=lifecycle,
workspace_provisioning=workspace_provisioning,
invitation_tokens=invitation_tokens,
activation_accounts=activation_accounts,
),
password=AccountPasswordService(
accounts=accounts,
passwords=passwords,
),
profile=AccountProfileService(accounts=accounts),
)
+1 -1
View File
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from sqlalchemy.orm import Session, sessionmaker
from repositories.account_repository import SQLAlchemyAccountRepository
from repositories.account.repository import SQLAlchemyAccountRepository
from repositories.app.api_key_repository import AppApiKeyRepository
from repositories.app.console_repository import ConsoleAppRepository
from services.agent.roster_package_exporter import RosterAgentPackageExporter
@@ -0,0 +1,107 @@
"""Compose workspace use cases with the shared account and workspace repositories."""
from dataclasses import dataclass
from sqlalchemy.orm import Session, sessionmaker
from extensions.ext_redis import RedisClientWrapper
from repositories.account.repository import SQLAlchemyAccountRepository
from repositories.workspace.workspace_repository import WorkspaceRepository
from services.account.adapters import RedisInvitationTokenStore
from services.file_service import FileService
from services.workspace.gateways import (
DeploymentWorkspaceCreationPolicy,
DeploymentWorkspaceFeatureGateway,
DeploymentWorkspaceMemberAccessGateway,
DeploymentWorkspaceMemberRoleResolver,
DeploymentWorkspacePlanGateway,
WorkspaceFileGateway,
WorkspaceIdentityGateway,
WorkspaceInvitationGateway,
WorkspaceOwnerTransferGateway,
WorkspaceProvisioningEffectsGateway,
)
from services.workspace.member_service import (
InvitedAccountRegistration,
WorkspaceInvitationService,
WorkspaceMemberQueryService,
WorkspaceMemberService,
WorkspaceOwnerTransferService,
)
from services.workspace.provisioning_service import WorkspaceProvisioningService
from services.workspace.service import WorkspaceQueryService, WorkspaceService
@dataclass(frozen=True, slots=True)
class WorkspaceServices:
queries: WorkspaceQueryService
management: WorkspaceService
provisioning: WorkspaceProvisioningService
members: WorkspaceMemberService
member_queries: WorkspaceMemberQueryService
owner_transfer: WorkspaceOwnerTransferService
invitations: WorkspaceInvitationService
identity: WorkspaceIdentityGateway
def build_workspace_membership_services(
*,
database_client: sessionmaker[Session],
workspaces: WorkspaceRepository,
accounts: SQLAlchemyAccountRepository,
) -> tuple[WorkspaceMemberService, WorkspaceProvisioningService]:
"""Build the workspace prerequisites shared by account registration and invitations."""
members = WorkspaceMemberService(
workspaces=workspaces,
accounts=accounts,
access=DeploymentWorkspaceMemberAccessGateway(session_factory=database_client),
)
provisioning = WorkspaceProvisioningService(
owners=accounts,
provisioning=workspaces,
effects=WorkspaceProvisioningEffectsGateway(session_factory=database_client),
policies=DeploymentWorkspaceCreationPolicy(),
memberships=workspaces,
members=members,
)
return members, provisioning
def build_workspace_services(
*,
workspaces: WorkspaceRepository,
accounts: SQLAlchemyAccountRepository,
files: FileService,
redis: RedisClientWrapper,
members: WorkspaceMemberService,
provisioning: WorkspaceProvisioningService,
registration: InvitedAccountRegistration,
invitation_tokens: RedisInvitationTokenStore,
) -> WorkspaceServices:
return WorkspaceServices(
queries=WorkspaceQueryService(workspaces=workspaces, plans=DeploymentWorkspacePlanGateway()),
management=WorkspaceService(
workspaces=workspaces,
features=DeploymentWorkspaceFeatureGateway(),
logos=WorkspaceFileGateway(files=files),
),
provisioning=provisioning,
members=members,
member_queries=WorkspaceMemberQueryService(
members=workspaces,
roles=DeploymentWorkspaceMemberRoleResolver(),
),
owner_transfer=WorkspaceOwnerTransferService(
accounts=accounts,
workspaces=workspaces,
members=members,
challenges=WorkspaceOwnerTransferGateway(redis=redis),
),
invitations=WorkspaceInvitationService(
accounts=registration,
workspaces=workspaces,
members=members,
invitations=WorkspaceInvitationGateway(tokens=invitation_tokens, redis=redis),
),
identity=WorkspaceIdentityGateway(workspaces=workspaces),
)
+78 -332
View File
@@ -6,7 +6,6 @@ import time
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from functools import partial
from typing import cast
from uuid import uuid4
@@ -17,11 +16,11 @@ from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from constants.dsl_version import CURRENT_APP_DSL_VERSION
from constants.languages import languages
from core.db.session_factory import get_session_maker
from core.schemas.schema_manager import SchemaManager
from core.tools.tool_file_manager import ToolFileManager
from enums import DeploymentEdition, WebAppAccessMode
from extensions.application_services.account import AccountServices, build_account_services
from extensions.application_services.agent import AgentAppServices, build_agent_app_services
from extensions.application_services.app import AppServices, build_app_api_key_service, build_app_services
from extensions.application_services.data_sources import (
@@ -37,23 +36,19 @@ from extensions.application_services.knowledge import (
build_knowledge_services,
)
from extensions.application_services.trial_app import TrialAppServices, build_trial_app_services
from extensions.application_services.workspace import (
WorkspaceServices,
build_workspace_membership_services,
build_workspace_services,
)
from extensions.ext_redis import RedisClientWrapper, redis_client
from extensions.ext_storage import storage
from libs.datetime_utils import naive_utc_now, utc_now
from libs.helper import RateLimiter
from libs.oauth import GitHubOAuth, GoogleOAuth
from libs.oauth_bearer import invalidate_oauth_token_cache
from libs.passport import PassportService
from models.model import EndUser
from repositories.account.repository import SQLAlchemyAccountRepository
from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository
from repositories.account_integration_repository import SQLAlchemyAccountIntegrationRepository
from repositories.account_oauth_repository import (
AccountServiceOAuthAccountRegistrationGateway,
AccountServiceOAuthSessionGateway,
AccountServiceOAuthWorkspaceGateway,
RegisterServiceOAuthInvitationGateway,
)
from repositories.account_repository import SQLAlchemyAccountRepository
from repositories.app.mcp_server_repository import AppMCPServerRepository
from repositories.app.site_command_repository import AppSiteCommandRepository
from repositories.app.tracing_config_repository import SQLAlchemyAppTracingConfigRepository
@@ -68,7 +63,6 @@ from repositories.file_grant_repository import FileGrantRepository
from repositories.human_input_file_upload_repository import SQLAlchemyHumanInputFileUploadRepository
from repositories.installation_state_repository import InstallationStateRepository
from repositories.message_file_preview_repository import MessageFilePreviewQueryRepository
from repositories.oauth_access_token_repository import SQLAlchemyOAuthAccessTokenRepository
from repositories.oauth_device_token_repository import SQLAlchemyOAuthDeviceTokenRepository
from repositories.oauth_server_repository import RedisOAuthServerTokenRepository, SQLAlchemyOAuthServerRepository
from repositories.plugin_file_upload_repository import SQLAlchemyPluginFileUploadOwnerRepository
@@ -83,79 +77,20 @@ from repositories.web_passport_repository import WebPassportRepository
from repositories.webapp_access_query_repository import WebAppAccessQueryRepository
from repositories.workflow_app_log_query_repository import WorkflowAppLogQueryRepository
from repositories.workflow_run_archive_repository import WorkflowRunArchiveBundleQueryRepository
from repositories.workspace_member_query_repository import WorkspaceMemberQueryRepository
from repositories.workspace_query_repository import WorkspaceQueryRepository
from services.account_access_service import AccountAccessService
from services.account_activation_service import AccountActivationService
from services.account_adapters import (
BillingAccountActivationEligibility,
BillingAccountDeletionFeedbackGateway,
BillingAccountEducationGateway,
BillingAccountEmailPolicyGateway,
BillingWorkspaceMembershipCache,
CeleryAccountDeletionScheduler,
CeleryAccountDeletionVerificationNotifier,
CeleryChangeEmailNotificationGateway,
DeploymentWorkspaceInvitePolicy,
EnterpriseAccountDeletionSyncGateway,
RateLimiterChangeEmailSendLimiter,
RBACWorkspaceMemberAccessSync,
RedisChangeEmailSecurityGateway,
from repositories.workspace.workspace_repository import WorkspaceRepository
from services.account.adapters import (
InstallationTelemetryGateway,
RedisInvitationTokenStore,
SecureChangeEmailCodeGenerator,
TokenManagerAccountDeletionVerificationGateway,
TokenManagerChangeEmailTokenGateway,
)
from services.account_avatar_file_gateway import SQLAlchemyAccountAvatarFileGateway
from services.account_avatar_service import AccountAvatarService
from services.account_change_email_service import AccountChangeEmailService
from services.account_deletion_feedback_service import AccountDeletionFeedbackService
from services.account_deletion_service import AccountDeletionService
from services.account_education_service import AccountEducationService
from services.account_email_registration_adapters import (
AccountServiceRegistrationGateway,
BillingAccountRegistrationPolicyGateway,
CeleryEmailRegistrationNotificationGateway,
RateLimiterEmailRegistrationSendLimiter,
RedisEmailRegistrationSecurityGateway,
SecureEmailRegistrationCodeGenerator,
TokenManagerEmailRegistrationTokenGateway,
from services.account.forgot_password_adapters import RedisForgotPasswordSecurityGateway
from services.account.forgot_password_service import (
FORGOT_PASSWORD_SEND_RATE_LIMIT_MAX_ATTEMPTS,
FORGOT_PASSWORD_SEND_RATE_LIMIT_PREFIX,
FORGOT_PASSWORD_SEND_RATE_LIMIT_WINDOW_SECONDS,
)
from services.account_email_registration_service import AccountEmailRegistrationService
from services.account_forgot_password_adapters import (
CeleryForgotPasswordNotificationGateway,
RateLimiterForgotPasswordSendLimiter,
RedisForgotPasswordSecurityGateway,
RedisForgotPasswordTokenGateway,
SecureForgotPasswordCodeGenerator,
SystemFeatureServiceForgotPasswordRegistrationPolicy,
)
from services.account_forgot_password_service import AccountForgotPasswordService
from services.account_initialization_service import AccountInitializationService
from services.account_integration_service import AccountIntegrationService
from services.account_login_adapters import (
AccountActivationConsoleAuthInvitationGateway,
DeploymentConsoleAuthPolicyGateway,
LoggingConsoleAuthAuditGateway,
RedisAccountSessionGateway,
RedisConsoleAuthSecurityGateway,
RedisEmailCodeGateway,
RedisResetPasswordEmailGateway,
SQLAlchemyAccountRefreshPreparationGateway,
SQLAlchemyConsoleAuthProvisioningGateway,
TurnstileHumanVerificationGateway,
)
from services.account_login_service import ConsoleAuthenticationService
from services.account_oauth_adapters import (
DeploymentOAuthPolicyGateway,
DifyOAuthProviderGateway,
RedisOAuthAccountClaimLock,
)
from services.account_oauth_service import AccountOAuthService, OAuthProviderGateway
from services.account.login_adapters import RedisConsoleAuthSecurityGateway
from services.account.service import AccountSetupProvisioner
from services.account_password_hasher import DefaultAccountPasswordHasher
from services.account_password_service import AccountPasswordService
from services.account_profile_service import AccountProfileService
from services.account_service import AccountService
from services.agent.roster_package_exporter import RosterAgentPackageExporter
from services.app.advanced_prompt_template_service import AdvancedPromptTemplateService
from services.app.api_key_service import AppApiKeyService
@@ -228,7 +163,7 @@ from services.retention.workflow_run.archive_download_task_cache import Workflow
from services.retention.workflow_run.archive_log_service import WorkflowRunArchiveService
from services.saved_message_service import SavedMessageService
from services.schema_definition_service import SchemaDefinitionService
from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner
from services.setup_adapters import RedisSetupLock
from services.setup_service import SetupService
from services.step_by_step_tour_service import StepByStepTourService
from services.system_feature_service import SystemFeatureService
@@ -237,7 +172,7 @@ from services.tool_file_download_service import ToolFileDownloadService
from services.upload_file_delivery_service import UploadFileDeliveryService
from services.web_app_runtime_query_service import WebAppRuntimeQueryService
from services.web_authentication_adapters import (
AccountServiceWebAuthenticationSecurityGateway,
AccountWebAuthenticationSecurityGateway,
LoggingWebAuthenticationAuditGateway,
PassportWebAppSessionGateway,
TokenManagerWebAuthenticationGateway,
@@ -253,10 +188,6 @@ from services.webapp_access_query_service import WebAppAccessQueryService, WebAp
from services.workflow_app_log_query_service import WorkflowAppLogQueryService
from services.workflow_run_service import WorkflowRunService
from services.workflow_statistic_query_service import WorkflowStatisticQueryService
from services.workspace_member_query_service import WorkspaceMemberQueryService
from services.workspace_member_role_resolver import DeploymentWorkspaceMemberRoleResolver
from services.workspace_plan_gateway import DeploymentWorkspacePlanGateway
from services.workspace_query_service import WorkspaceQueryService
from tasks.mail_inner_task import enqueue_inner_mail
logger = logging.getLogger(__name__)
@@ -299,24 +230,6 @@ def _batch_get_enterprise_webapp_user_permissions(*, user_id: str, app_ids: Sequ
return {app_id: bool(allowed) for app_id, allowed in permissions.items()}
@dataclass(frozen=True, slots=True)
class AccountServices:
access: AccountAccessService
authentication: ConsoleAuthenticationService
avatar: AccountAvatarService
change_email: AccountChangeEmailService
email_registration: AccountEmailRegistrationService
deletion: AccountDeletionService
deletion_feedback: AccountDeletionFeedbackService
education: AccountEducationService
forgot_password: AccountForgotPasswordService
initialization: AccountInitializationService
integrations: AccountIntegrationService
oauth: AccountOAuthService
password: AccountPasswordService
profile: AccountProfileService
@dataclass(frozen=True, slots=True)
class AppScopedEndUserServices:
commands: AppScopedEndUserService[EndUser]
@@ -331,7 +244,6 @@ class ApplicationServices:
accounts: AccountServices
app_api_keys: AppApiKeyService
dataset_api_keys: DatasetApiKeyService
account_activation: AccountActivationService
apps: AppServices
app_definitions: AppDefinitionQueryService
app_mcp_servers: AppMCPServerService
@@ -376,8 +288,7 @@ class ApplicationServices:
trial_apps: TrialAppServices
workflow_run_archives: WorkflowRunArchiveService
workflow_runs: WorkflowRunService
workspace_queries: WorkspaceQueryService
workspace_member_queries: WorkspaceMemberQueryService
workspaces: WorkspaceServices
workflow_app_logs: WorkflowAppLogQueryService
inner_mail: InnerMailService
web_passport: WebPassportService
@@ -454,55 +365,6 @@ def _build_file_grant_service(*, database_client: sessionmaker[Session]) -> File
)
def _build_account_oauth_service(
*,
database_client: sessionmaker[Session],
deployment_edition: DeploymentEdition,
redis: RedisClientWrapper,
accounts: SQLAlchemyAccountRepository,
integrations: SQLAlchemyAccountIntegrationRepository,
memberships: WorkspaceQueryRepository,
) -> AccountOAuthService:
providers: dict[str, OAuthProviderGateway] = {}
if dify_config.GITHUB_CLIENT_ID and dify_config.GITHUB_CLIENT_SECRET:
providers["github"] = DifyOAuthProviderGateway(
provider_name="github",
client=GitHubOAuth(
client_id=dify_config.GITHUB_CLIENT_ID,
client_secret=dify_config.GITHUB_CLIENT_SECRET,
redirect_uri=dify_config.CONSOLE_API_URL + "/console/api/oauth/authorize/github",
),
)
if dify_config.GOOGLE_CLIENT_ID and dify_config.GOOGLE_CLIENT_SECRET:
providers["google"] = DifyOAuthProviderGateway(
provider_name="google",
client=GoogleOAuth(
client_id=dify_config.GOOGLE_CLIENT_ID,
client_secret=dify_config.GOOGLE_CLIENT_SECRET,
redirect_uri=dify_config.CONSOLE_API_URL + "/console/api/oauth/authorize/google",
),
)
policy = DeploymentOAuthPolicyGateway(
billing_enabled=deployment_edition == DeploymentEdition.CLOUD,
)
return AccountOAuthService(
providers=providers,
accounts=accounts,
integrations=integrations,
memberships=memberships,
invitations=RegisterServiceOAuthInvitationGateway(session_factory=database_client),
account_claims=RedisOAuthAccountClaimLock(client=redis),
registration=AccountServiceOAuthAccountRegistrationGateway(session_factory=database_client),
workspaces=AccountServiceOAuthWorkspaceGateway(session_factory=database_client),
sessions=AccountServiceOAuthSessionGateway(session_factory=database_client),
registration_policy=policy,
workspace_policy=policy,
supported_languages=languages,
now=naive_utc_now,
)
def build_application_services(
*,
database_client: sessionmaker[Session],
@@ -541,6 +403,7 @@ def build_application_services(
database=database_catalog,
builtin=builtin_catalog,
)
workspace_repository = WorkspaceRepository(session_factory=database_client)
recommended_app_queries = RecommendedAppQueryService(
catalog=recommended_app_catalog,
trial_apps=trial_apps,
@@ -549,11 +412,9 @@ def build_application_services(
recommended_app_packages = RecommendedAppPackageService(
sources=database_catalog, exporter=RosterAgentPackageExporter()
)
workspace_query_repository = WorkspaceQueryRepository(session_factory=database_client)
workspace_member_repository = WorkspaceMemberQueryRepository(session_factory=database_client)
dataset_dependencies = build_dataset_dependencies(
database_client=database_client,
workspace_roles=workspace_member_repository,
workspace_roles=workspace_repository,
)
datasource_credentials = build_data_source_credentials(database_client=database_client)
data_sources = build_data_source_services(
@@ -588,173 +449,51 @@ def build_application_services(
remote_file_service = RemoteFileService(files=file_service)
passwords = DefaultAccountPasswordHasher()
web_authentication_tokens = TokenManagerWebAuthenticationGateway(
reset_password_rate_limiter=AccountService.reset_password_rate_limiter,
reset_password_rate_limiter=RateLimiter(
prefix=FORGOT_PASSWORD_SEND_RATE_LIMIT_PREFIX,
max_attempts=FORGOT_PASSWORD_SEND_RATE_LIMIT_MAX_ATTEMPTS,
time_window=FORGOT_PASSWORD_SEND_RATE_LIMIT_WINDOW_SECONDS,
redis_client=redis,
),
access_token_expire_minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES,
)
invitation_tokens = RedisInvitationTokenStore(redis=redis)
activation_accounts = SQLAlchemyAccountActivationRepository(session_factory=database_client)
account_provisioning = SQLAlchemyConsoleAuthProvisioningGateway(session_factory=database_client)
workflow_run_repository = DifyAPISQLAlchemyWorkflowRunRepository(session_maker=database_client)
workflow_node_execution_repository = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
session_maker=database_client
)
workspace_members, workspace_provisioning = build_workspace_membership_services(
database_client=database_client,
workspaces=workspace_repository,
accounts=accounts,
)
account_services = build_account_services(
database_client=database_client,
deployment_edition=deployment_edition,
redis=redis,
accounts=accounts,
integrations=integrations,
workspace_repository=workspace_repository,
workspace_provisioning=workspace_provisioning,
passwords=passwords,
invitation_tokens=invitation_tokens,
activation_accounts=activation_accounts,
)
workspace_services = build_workspace_services(
workspaces=workspace_repository,
accounts=accounts,
files=file_service,
redis=redis,
members=workspace_members,
provisioning=workspace_provisioning,
registration=account_services.lifecycle,
invitation_tokens=invitation_tokens,
)
return ApplicationServices(
credential_queries=CredentialQueryRepository(session_factory=database_client),
accounts=AccountServices(
access=AccountAccessService(
accounts=accounts,
workspaces=workspace_query_repository,
sessions=SQLAlchemyOAuthAccessTokenRepository(session_factory=database_client),
invalidate_token_cache=partial(invalidate_oauth_token_cache, redis),
now=utc_now,
),
authentication=ConsoleAuthenticationService(
accounts=accounts,
workspaces=workspace_query_repository,
invitations=AccountActivationConsoleAuthInvitationGateway(
tokens=invitation_tokens,
accounts=activation_accounts,
),
policies=DeploymentConsoleAuthPolicyGateway(
billing_enabled=deployment_edition == DeploymentEdition.CLOUD,
),
security=RedisConsoleAuthSecurityGateway(redis=redis),
passwords=passwords,
human_verification=TurnstileHumanVerificationGateway(),
sessions=RedisAccountSessionGateway(redis=redis),
refresh_preparation=SQLAlchemyAccountRefreshPreparationGateway(session_factory=database_client),
account_provisioning=account_provisioning,
workspace_provisioning=account_provisioning,
email_codes=RedisEmailCodeGateway(redis=redis),
reset_password_emails=RedisResetPasswordEmailGateway(redis=redis),
audit=LoggingConsoleAuthAuditGateway(),
now=naive_utc_now,
turnstile_enabled=deployment_edition == DeploymentEdition.CLOUD,
turnstile_verify_required=(
deployment_edition == DeploymentEdition.CLOUD and dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED
),
),
avatar=AccountAvatarService(
files=SQLAlchemyAccountAvatarFileGateway(session_factory=database_client),
),
change_email=AccountChangeEmailService(
accounts=accounts,
tokens=TokenManagerChangeEmailTokenGateway(),
codes=SecureChangeEmailCodeGenerator(),
notifications=CeleryChangeEmailNotificationGateway(),
send_limits=RateLimiterChangeEmailSendLimiter(redis=redis),
security=RedisChangeEmailSecurityGateway(
redis=redis,
email_send_ip_limit_per_minute=dify_config.EMAIL_SEND_IP_LIMIT_PER_MINUTE,
verification_failure_limit=5,
verification_lockout_duration=dify_config.CHANGE_EMAIL_LOCKOUT_DURATION,
),
email_policy=BillingAccountEmailPolicyGateway(
billing_enabled=deployment_edition == DeploymentEdition.CLOUD,
),
),
email_registration=AccountEmailRegistrationService(
accounts=accounts,
tokens=TokenManagerEmailRegistrationTokenGateway(),
codes=SecureEmailRegistrationCodeGenerator(),
notifications=CeleryEmailRegistrationNotificationGateway(),
send_limits=RateLimiterEmailRegistrationSendLimiter(
rate_limiter=RateLimiter(
prefix="email_register_rate_limit",
max_attempts=1,
time_window=60,
redis_client=redis,
)
),
security=RedisEmailRegistrationSecurityGateway(
redis=redis,
verification_failure_limit=5,
verification_lockout_duration=dify_config.EMAIL_REGISTER_LOCKOUT_DURATION,
),
account_policy=BillingAccountRegistrationPolicyGateway(
enabled=deployment_edition == DeploymentEdition.CLOUD,
),
registration=AccountServiceRegistrationGateway(session_factory=database_client),
),
deletion=AccountDeletionService(
accounts=accounts,
memberships=workspace_query_repository,
verification=TokenManagerAccountDeletionVerificationGateway(),
notifications=CeleryAccountDeletionVerificationNotifier(redis=redis),
synchronization=EnterpriseAccountDeletionSyncGateway(),
scheduler=CeleryAccountDeletionScheduler(),
),
deletion_feedback=AccountDeletionFeedbackService(
feedback=BillingAccountDeletionFeedbackGateway(),
),
education=AccountEducationService(
accounts=accounts,
education=BillingAccountEducationGateway(),
verification_rate_limiter=RateLimiter(
prefix="edu_verification_rate_limit",
max_attempts=10,
time_window=60,
redis_client=redis,
),
activation_rate_limiter=RateLimiter(
prefix="edu_activation_rate_limit",
max_attempts=10,
time_window=60,
redis_client=redis,
),
),
forgot_password=AccountForgotPasswordService(
accounts=accounts,
passwords=passwords,
tokens=RedisForgotPasswordTokenGateway(
redis=redis,
expiry_seconds=dify_config.RESET_PASSWORD_TOKEN_EXPIRY_MINUTES * 60,
),
codes=SecureForgotPasswordCodeGenerator(),
notifications=CeleryForgotPasswordNotificationGateway(),
send_limits=RateLimiterForgotPasswordSendLimiter(redis=redis),
security=RedisForgotPasswordSecurityGateway(
redis=redis,
email_send_ip_limit_per_minute=dify_config.EMAIL_SEND_IP_LIMIT_PER_MINUTE,
verification_lockout_duration=dify_config.FORGOT_PASSWORD_LOCKOUT_DURATION,
),
registration=SystemFeatureServiceForgotPasswordRegistrationPolicy(),
),
initialization=AccountInitializationService(
accounts=accounts,
invitation_required=deployment_edition == DeploymentEdition.CLOUD,
now=naive_utc_now,
),
integrations=AccountIntegrationService(integrations=integrations),
oauth=_build_account_oauth_service(
database_client=database_client,
deployment_edition=deployment_edition,
redis=redis,
accounts=accounts,
integrations=integrations,
memberships=workspace_query_repository,
),
password=AccountPasswordService(
accounts=accounts,
passwords=passwords,
),
profile=AccountProfileService(accounts=accounts),
),
account_activation=AccountActivationService(
tokens=invitation_tokens,
accounts=activation_accounts,
workspace_policy=DeploymentWorkspaceInvitePolicy(),
eligibility=BillingAccountActivationEligibility(
enabled=deployment_edition == DeploymentEdition.CLOUD,
),
membership_cache=BillingWorkspaceMembershipCache(
enabled=deployment_edition == DeploymentEdition.CLOUD,
),
member_access_sync=RBACWorkspaceMemberAccessSync(
enabled=dify_config.RBAC_ENABLED,
),
),
accounts=account_services,
apps=apps,
credential_queries=CredentialQueryRepository(session_factory=database_client),
agent_apps=build_agent_app_services(database_client=database_client),
advanced_prompt_templates=AdvancedPromptTemplateService(),
app_definitions=app_definitions,
@@ -804,8 +543,13 @@ def build_application_services(
accounts=accounts,
passwords=passwords,
tokens=web_authentication_tokens,
security=AccountServiceWebAuthenticationSecurityGateway(
account_service=AccountService,
security=AccountWebAuthenticationSecurityGateway(
password_security=RedisForgotPasswordSecurityGateway(
redis=redis,
email_send_ip_limit_per_minute=dify_config.EMAIL_SEND_IP_LIMIT_PER_MINUTE,
verification_lockout_duration=dify_config.FORGOT_PASSWORD_LOCKOUT_DURATION,
),
login_security=RedisConsoleAuthSecurityGateway(redis=redis),
),
app_access=webapp_access,
app_sessions=PassportWebAppSessionGateway(
@@ -818,7 +562,7 @@ def build_application_services(
installed_apps=build_installed_app_services(
database_client=database_client,
webapp_access=webapp_access,
get_workspace_role=workspace_query_repository.get_account_role,
get_workspace_role=workspace_repository.get_account_role,
webapp_auth_enabled=webapp_auth_enabled,
),
web_app_runtime=WebAppRuntimeQueryService(
@@ -835,7 +579,12 @@ def build_application_services(
schema_definitions=SchemaDefinitionService(source_factory=SchemaManager),
setup=SetupService(
state=installation_state,
accounts=RegisterServiceAccountProvisioner(session_factory=database_client),
accounts=AccountSetupProvisioner(
accounts=account_services.lifecycle,
workspaces=workspace_services.provisioning,
installation=installation_state,
telemetry=InstallationTelemetryGateway(session_factory=database_client),
),
lock=RedisSetupLock(client=redis),
setup_required=deployment_edition != DeploymentEdition.CLOUD,
),
@@ -872,7 +621,7 @@ def build_application_services(
database_client=database_client,
redis=redis,
accounts=accounts,
workspaces=workspace_query_repository,
workspaces=workspace_repository,
),
init_validation=InitValidationService(
state=installation_state,
@@ -910,14 +659,7 @@ def build_application_services(
workflow_runs=workflow_run_repository,
node_executions=workflow_node_execution_repository,
),
workspace_queries=WorkspaceQueryService(
workspaces=workspace_query_repository,
plans=DeploymentWorkspacePlanGateway(),
),
workspace_member_queries=WorkspaceMemberQueryService(
members=workspace_member_repository,
roles=DeploymentWorkspaceMemberRoleResolver(),
),
workspaces=workspace_services,
workflow_app_logs=WorkflowAppLogQueryService(
logs=WorkflowAppLogQueryRepository(session_factory=database_client),
),
@@ -945,12 +687,16 @@ def build_application_services(
def init_app(app: Flask) -> None:
app.extensions[_EXTENSION_KEY] = build_application_services(
from extensions.ext_login import bind_account_loader
services = build_application_services(
database_client=get_session_maker(),
deployment_edition=dify_config.DEPLOYMENT_EDITION,
initialization_password=dify_config.INIT_PASSWORD,
redis=redis_client,
)
app.extensions[_EXTENSION_KEY] = services
bind_account_loader(app, services.accounts.identity.load_user)
def application_services() -> ApplicationServices:
+14 -5
View File
@@ -1,9 +1,10 @@
import json
import logging
from collections.abc import Callable
from typing import assert_never, cast, override
import flask_login
from flask import Request, Response, request
from flask import Flask, Request, Response, current_app, request
from flask_login import user_loaded_from_request, user_logged_in
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -24,11 +25,19 @@ from libs.token import (
from models import Account, Tenant, TenantAccountJoin
from models.enums import EndUserType
from models.model import AppMCPServer, EndUser
from services.account_service import AccountService
logger = logging.getLogger(__name__)
type LoginUser = Account | EndUser
type AccountLoader = Callable[[str], Account | None]
def bind_account_loader(app: Flask, loader: AccountLoader) -> None:
app.extensions["account_identity_loader"] = loader
def load_account(account_id: str) -> Account | None:
return cast(AccountLoader, current_app.extensions["account_identity_loader"])(account_id)
class DifyLoginManager(flask_login.LoginManager):
@@ -99,7 +108,7 @@ def _load_user_from_request(request_from_flask_login: Request, session: Session)
if not user_id:
raise Unauthorized("Invalid Authorization token.")
logged_in_account = AccountService.load_logged_in_account(account_id=user_id, session=session)
logged_in_account = load_account(user_id)
return logged_in_account
elif request.blueprint == "openapi":
# Account-branch device-flow approval routes (approve / deny /
@@ -118,7 +127,7 @@ def _load_user_from_request(request_from_flask_login: Request, session: Session)
source = decoded.get("token_source")
if source or not user_id:
return None
return AccountService.load_logged_in_account(account_id=user_id, session=session)
return load_account(user_id)
elif request.blueprint == "web":
app_code = request.headers.get(HEADER_NAME_APP_CODE)
webapp_token = extract_webapp_passport(app_code, request) if app_code else None
@@ -166,7 +175,7 @@ def _load_user_from_request(request_from_flask_login: Request, session: Session)
def on_user_logged_in(_sender: object, user: LoginUser) -> None:
"""Snapshot authenticated identity into the side-effect-free logging context.
Note: AccountService.load_logged_in_account will populate user.current_tenant_id
Note: AccountIdentityGateway.load_user will populate user.current_tenant_id
through the load_user method, which calls account.set_tenant_id_with_session().
"""
set_identity_context()
+2 -61
View File
@@ -2,7 +2,7 @@ import enum
import json
from dataclasses import field
from datetime import datetime
from typing import Optional, TypedDict
from typing import TypedDict
from uuid import uuid4
import sqlalchemy as sa
@@ -12,71 +12,12 @@ from sqlalchemy.orm import Mapped, Session, mapped_column
from typing_extensions import deprecated
from configs import dify_config
from enums.account import TenantAccountRole
from .base import TypeBase
from .types import EnumText, LongText, StringUUID
class TenantAccountRole(enum.StrEnum):
OWNER = "owner"
ADMIN = "admin"
EDITOR = "editor"
NORMAL = "normal"
DATASET_OPERATOR = "dataset_operator"
@staticmethod
def is_valid_role(role: str) -> bool:
if not role:
return False
return role in {
TenantAccountRole.OWNER,
TenantAccountRole.ADMIN,
TenantAccountRole.EDITOR,
TenantAccountRole.NORMAL,
TenantAccountRole.DATASET_OPERATOR,
}
@staticmethod
def is_privileged_role(role: Optional["TenantAccountRole"]) -> bool:
if not role:
return False
return role in {TenantAccountRole.OWNER, TenantAccountRole.ADMIN}
@staticmethod
def is_admin_role(role: Optional["TenantAccountRole"]) -> bool:
if not role:
return False
return role == TenantAccountRole.ADMIN
@staticmethod
def is_non_owner_role(role: Optional["TenantAccountRole"]) -> bool:
if not role:
return False
return role in {
TenantAccountRole.ADMIN,
TenantAccountRole.EDITOR,
TenantAccountRole.NORMAL,
TenantAccountRole.DATASET_OPERATOR,
}
@staticmethod
def is_editing_role(role: Optional["TenantAccountRole"]) -> bool:
if not role:
return False
return role in {TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR}
@staticmethod
def is_dataset_edit_role(role: Optional["TenantAccountRole"]) -> bool:
if not role:
return False
return role in {
TenantAccountRole.OWNER,
TenantAccountRole.ADMIN,
TenantAccountRole.EDITOR,
TenantAccountRole.DATASET_OPERATOR,
}
class AccountStatus(enum.StrEnum):
PENDING = "pending"
UNINITIALIZED = "uninitialized"
-4
View File
@@ -10848,10 +10848,6 @@ Update a plugin endpoint
| 200 | Available models retrieved successfully | **application/json**: [AvailableModelListResponse](#availablemodellistresponse)<br> |
### [GET] /workspaces/current/permission
**Get workspace permission settings**
Returns permission flags that control workspace features like member invitations and owner transfer.
#### Responses
| Code | Description | Schema |
Whitespace-only changes.
@@ -3,12 +3,23 @@
from datetime import datetime
from typing import override
from sqlalchemy import case, delete, func, select
from sqlalchemy import case, delete, func, select, update
from sqlalchemy.orm import Session, sessionmaker
from models.account import Account, AccountIntegrate, AccountStatus, InvitationCode, InvitationCodeStatus
from models.account import (
Account,
AccountIntegrate,
AccountStatus,
InvitationCode,
InvitationCodeStatus,
Tenant,
TenantAccountJoin,
TenantStatus,
)
from services.account.contracts import AccountCreation
from services.account.login_service import ConsoleAuthAccountRepository
from services.account_email import normalize_email
from services.account_login_service import ConsoleAuthAccountRepository
from services.account_errors import AccountNormalizedEmailAlreadyInUseError
from services.account_ports import AccountRepository
from services.entities.account_entities import (
AccountAuthenticationSnapshot,
@@ -35,18 +46,136 @@ class SQLAlchemyAccountRepository(AccountRepository, ConsoleAuthAccountRepositor
@override
def get(self, account_id: str) -> AccountSnapshot | None:
account = self.get_model(account_id)
return self._to_snapshot(account) if account is not None else None
def get_model(self, account_id: str) -> Account | None:
"""Load a detached identity for framework adapters that require Account."""
with self._session_factory() as session:
return session.get(Account, account_id)
def find_identity(self, email: str, *, case_fallback: bool = False) -> Account | None:
"""Prefer an exact email match, then the oldest normalized match when requested.
SSO identities can retain mixed-case emails while callers supply lowercase
addresses. Normalized emails are indexed but may have historical duplicates.
"""
with self._session_factory() as session:
account = session.execute(select(Account).where(Account.email == email)).scalar_one_or_none()
if account is None and case_fallback:
account = session.execute(
select(Account)
.where(Account.normalized_email == normalize_email(email))
.order_by(Account.created_at)
.limit(1)
).scalar_one_or_none()
return account
def load_identity(self, account_id: str, *, now: datetime) -> Account | None:
"""Resolve an active workspace and materialize the Flask identity before closing its Session."""
with self._session_factory(expire_on_commit=False) as session:
account = session.get(Account, account_id)
return self._to_snapshot(account) if account is not None else None
if account is None or account.status == AccountStatus.BANNED:
return account
membership = session.scalar(
select(TenantAccountJoin)
.join(Tenant, Tenant.id == TenantAccountJoin.tenant_id)
.where(
TenantAccountJoin.account_id == account_id,
TenantAccountJoin.current.is_(True),
Tenant.status == TenantStatus.NORMAL,
)
.limit(1)
)
if membership is None:
session.execute(
update(TenantAccountJoin)
.where(TenantAccountJoin.account_id == account_id, TenantAccountJoin.current.is_(True))
.values(current=False)
)
membership = session.scalar(
select(TenantAccountJoin)
.join(Tenant, Tenant.id == TenantAccountJoin.tenant_id)
.where(
TenantAccountJoin.account_id == account_id,
Tenant.status == TenantStatus.NORMAL,
)
.order_by(TenantAccountJoin.id.asc())
.limit(1)
)
if membership is None:
session.commit()
return None
membership.current = True
membership.last_opened_at = now
session.commit()
account.set_tenant_id_with_session(membership.tenant_id, session=session)
return account
def touch_activity(self, account_id: str, *, now: datetime, before: datetime, touch_updated_at: bool) -> None:
with self._session_factory.begin() as session:
values: dict[str, object] = {"last_active_at": now}
if touch_updated_at:
values["updated_at"] = func.current_timestamp()
session.execute(
update(Account).where(Account.id == account_id, Account.last_active_at < before).values(**values)
)
@override
def find_by_email(self, email: str) -> AccountSnapshot | None:
account = self.find_identity(email, case_fallback=True)
return self._to_snapshot(account) if account is not None else None
def find_first_by_email(self, email: str) -> AccountSnapshot | None:
"""Preserve internal provisioning's exact, first-match email lookup.
Accounts may still have duplicate email addresses during normalization;
unlike authentication, this endpoint historically selects one match.
"""
with self._session_factory() as session:
account = session.execute(select(Account).where(Account.email == email)).scalar_one_or_none()
if account is None and email != email.lower():
account = session.execute(select(Account).where(Account.email == email.lower())).scalar_one_or_none()
account = session.scalar(select(Account).where(Account.email == email).limit(1))
return self._to_snapshot(account) if account is not None else None
def normalized_email_exists(self, email: str) -> bool:
with self._session_factory() as session:
return (
session.scalar(select(Account.id).where(Account.normalized_email == normalize_email(email)).limit(1))
is not None
)
def create(self, creation: AccountCreation) -> AccountSnapshot:
with self._session_factory.begin() as session:
return self._to_snapshot(self.add(creation, session=session))
@staticmethod
def add(creation: AccountCreation, *, session: Session) -> Account:
"""Share account insertion with aggregate repositories without owning their commit."""
if (
creation.check_normalized_email
and session.scalar(
select(Account.id).where(Account.normalized_email == normalize_email(creation.email)).limit(1)
)
is not None
):
raise AccountNormalizedEmailAlreadyInUseError("An account with an equivalent email already exists.")
account = Account(
name=creation.name,
email=creation.email,
normalized_email=normalize_email(creation.email),
password=creation.password.password_hash if creation.password else None,
password_salt=creation.password.password_salt if creation.password else None,
interface_language=creation.interface_language,
interface_theme=creation.interface_theme,
timezone=creation.timezone,
last_login_ip=creation.ip_address,
status=AccountStatus(creation.status),
initialized_at=creation.initialized_at,
)
account.id = creation.id
session.add(account)
session.flush()
return account
@override
def activate_pending(self, account_id: str, *, initialized_at: datetime) -> None:
with self._session_factory.begin() as session:
@@ -208,6 +337,16 @@ class SQLAlchemyAccountRepository(AccountRepository, ConsoleAuthAccountRepositor
with self._session_factory() as session:
return session.scalar(select(Account.id).where(Account.email == email).limit(1)) is not None
def set_email(self, account_id: str, email: str) -> None:
with self._session_factory.begin() as session:
account = session.get(Account, account_id)
if account is None:
from services.account_errors import AccountNotFoundError
raise AccountNotFoundError()
account.email = email
account.normalized_email = normalize_email(email)
@override
def has_active_email(self, email: str) -> bool:
normalized = email.strip().lower()
@@ -1,128 +0,0 @@
"""Persistence-backed gateways for Console account OAuth sign-in."""
from typing import override
from sqlalchemy.orm import Session, sessionmaker
from libs.datetime_utils import naive_utc_now
from models.account import Account, AccountStatus
from services.account_errors import (
AccountEmailDomainSuspendedError,
OAuthAccountNotFoundError,
OAuthRegistrationError,
OAuthSeatsLimitExceededError,
OAuthWorkspaceCreationNotAllowedError,
)
from services.account_oauth_service import (
OAuthAccountRegistrationGateway,
OAuthInvitationGateway,
OAuthSessionGateway,
OAuthWorkspaceGateway,
)
from services.account_service import AccountService, RegisterService, TenantService
from services.enterprise.enterprise_service import try_join_default_workspace
from services.entities.account_entities import AccountSessionTokens
from services.entities.account_oauth_entities import (
OAuthAccountRegistration,
OAuthInvitation,
)
from services.errors.account import AccountRegisterError, EmailDomainSuspendedError, SeatsLimitExceededError
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
class RegisterServiceOAuthInvitationGateway(OAuthInvitationGateway):
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def resolve(self, invite_token: str) -> OAuthInvitation | None:
with self._session_factory() as session:
invitation = RegisterService.get_invitation_if_token_valid(
None,
None,
invite_token,
session=session,
)
if invitation is None:
return None
account = invitation["account"]
return OAuthInvitation(
account_id=account.id,
account_email=account.email,
account_status=account.status.value,
)
class AccountServiceOAuthAccountRegistrationGateway(OAuthAccountRegistrationGateway):
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def register(self, registration: OAuthAccountRegistration) -> str:
with self._session_factory() as session:
try:
account = AccountService.create_account(
email=registration.email,
name=registration.name,
interface_language=registration.language,
password=None,
timezone=registration.timezone,
ip_address=registration.ip_address,
check_normalized_email=True,
session=session,
)
account.status = AccountStatus.ACTIVE
account.initialized_at = naive_utc_now()
session.commit()
except EmailDomainSuspendedError as exc:
raise AccountEmailDomainSuspendedError from exc
except SeatsLimitExceededError as exc:
raise OAuthSeatsLimitExceededError from exc
except AccountRegisterError as exc:
raise OAuthRegistrationError(exc.description) from exc
except Exception as exc:
session.rollback()
raise OAuthRegistrationError(f"Registration failed: {exc}") from exc
return account.id
class AccountServiceOAuthWorkspaceGateway(OAuthWorkspaceGateway):
"""Adapt account workspace operations to the OAuth application port."""
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def create_owner_workspace(self, account_id: str) -> None:
with self._session_factory() as session:
account = session.get(Account, account_id)
if account is None:
raise OAuthAccountNotFoundError
try:
TenantService.create_owner_tenant(account, session=session)
except (WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError) as exc:
raise OAuthWorkspaceCreationNotAllowedError from exc
@override
def try_join_default_workspace(self, account_id: str) -> None:
try_join_default_workspace(account_id)
class AccountServiceOAuthSessionGateway(OAuthSessionGateway):
"""Adapt Console session issuance to the OAuth application port."""
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def login(self, account_id: str, *, ip_address: str) -> AccountSessionTokens:
with self._session_factory() as session:
account = session.get(Account, account_id)
if account is None:
raise OAuthAccountNotFoundError
token_pair = AccountService.login(account=account, session=session, ip_address=ip_address)
return AccountSessionTokens(
access_token=token_pair.access_token,
refresh_token=token_pair.refresh_token,
csrf_token=token_pair.csrf_token,
)
+1 -1
View File
@@ -70,7 +70,7 @@ from services.entities.app_entities import (
StarredAppListParams,
UpdateAppParams,
)
from services.errors.account import NoPermissionError
from services.errors.base import NoPermissionError
from services.openapi.visibility import apply_openapi_gate, is_openapi_visible
logger = logging.getLogger(__name__)
@@ -1,11 +1,13 @@
"""Persistence adapter for installation setup and tenant-existence state."""
from datetime import datetime
from uuid import uuid4
from sqlalchemy import exists, select
from sqlalchemy import delete, exists, select
from sqlalchemy.orm import Session, sessionmaker
from models.account import Tenant
from configs import dify_config
from models.account import Account, Tenant, TenantAccountJoin
from models.model import DifySetup
@@ -25,3 +27,12 @@ class InstallationStateRepository:
def has_tenants(self) -> bool:
with self._session_factory() as session:
return session.scalar(select(exists().select_from(Tenant))) is True
def mark_setup(self) -> None:
with self._session_factory.begin() as session:
session.add(DifySetup(version=dify_config.project.version, instance_id=str(uuid4())))
def reset_setup(self) -> None:
with self._session_factory.begin() as session:
for model in (DifySetup, TenantAccountJoin, Account, Tenant):
session.execute(delete(model))
+1
View File
@@ -0,0 +1 @@
"""Workspace domain modules."""
@@ -0,0 +1,561 @@
"""Workspace persistence shared by account queries and workspace use cases."""
from collections.abc import Callable, Sequence
from dataclasses import replace
from typing import override
from sqlalchemy import func, select, update
from sqlalchemy.orm import Session, sessionmaker
from libs.datetime_utils import naive_utc_now
from libs.pagination import paginate_query
from models import TenantCreditPool
from models.account import (
Account,
AccountStatus,
Tenant,
TenantAccountJoin,
TenantAccountRole,
TenantPluginAutoUpgradeCategory,
TenantPluginAutoUpgradeMode,
TenantPluginAutoUpgradeStrategy,
TenantPluginAutoUpgradeStrategySetting,
TenantStatus,
)
from models.dataset import Dataset
from models.enums import ProviderQuotaType
from models.model import App
from repositories.account.repository import SQLAlchemyAccountRepository
from services.account.contracts import AccountCreation
from services.account.login_service import ConsoleAuthWorkspaceQuery
from services.account_ports import AccountWorkspaceMembershipQuery, AccountWorkspaceSnapshotQuery
from services.entities.account_access_entities import AccountWorkspaceSnapshot
from services.errors.workspace import (
MemberNotInTenantError,
WorkspaceNotFoundError,
WorkspaceNotLinkedError,
WorkspaceOwnerNotFoundError,
)
from services.oauth_device_application_service import DeviceWorkspaceQuery
from services.oauth_device_contracts import DeviceWorkspace
from services.workspace.contracts import (
CreatedWorkspace,
WorkspaceCreation,
WorkspaceCustomConfig,
WorkspaceMemberRecord,
WorkspaceMemberRemoval,
WorkspaceMemberWrite,
WorkspacePage,
WorkspaceRecord,
WorkspaceSnapshot,
)
from services.workspace.member_service import WorkspaceMemberQuery
from services.workspace.service import WorkspaceQuery, WorkspaceStore
class WorkspaceRepository(
WorkspaceStore,
WorkspaceQuery,
AccountWorkspaceMembershipQuery,
AccountWorkspaceSnapshotQuery,
ConsoleAuthWorkspaceQuery,
DeviceWorkspaceQuery,
WorkspaceMemberQuery,
):
def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
def get_account_role(self, *, account_id: str, tenant_id: str) -> str | None:
"""Read the current membership role without loading or mutating an Account."""
stmt = (
select(TenantAccountJoin.role)
.where(TenantAccountJoin.account_id == account_id, TenantAccountJoin.tenant_id == tenant_id)
.limit(1)
)
with self._session_factory() as session:
role = session.scalar(stmt)
return role.value if role is not None else None
def provision(
self,
prepare: Callable[[], WorkspaceCreation],
*,
owner_id: str | None,
account: AccountCreation | None = None,
only_if_no_active_workspace: bool = False,
) -> CreatedWorkspace | None:
"""Commit the account, workspace, membership and defaults as one aggregate.
Initial-workspace creation holds the owner's row lock from the existence
check through preparation and commit. Preparation deliberately runs inside
this transaction: private-key writes and RBAC bindings cannot be rolled
back locally, so a competing sign-in must skip before creating either.
The application supplies these external actions; this repository only
controls when they can run. Notifications run after this method commits.
"""
with self._session_factory.begin() as session:
if account is not None:
owner_id = SQLAlchemyAccountRepository.add(account, session=session).id
elif owner_id is not None:
owner = session.get(Account, owner_id, with_for_update=only_if_no_active_workspace)
if owner is None:
raise WorkspaceOwnerNotFoundError()
if (
only_if_no_active_workspace
and session.scalar(
select(Tenant.id)
.join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id)
.where(TenantAccountJoin.account_id == owner_id, Tenant.status == TenantStatus.NORMAL)
.limit(1)
)
is not None
):
return None
creation = prepare()
tenant = Tenant(name=creation.name, encrypt_public_key=creation.encrypt_public_key)
tenant.id = creation.id
session.add(tenant)
if owner_id is not None:
session.add(TenantAccountJoin(tenant_id=tenant.id, account_id=owner_id, role=TenantAccountRole.OWNER))
for category, setting in creation.plugin_upgrade_settings.items():
session.add(
TenantPluginAutoUpgradeStrategy(
tenant_id=tenant.id,
category=TenantPluginAutoUpgradeCategory(category),
strategy_setting=TenantPluginAutoUpgradeStrategySetting(setting),
upgrade_time_of_day=creation.plugin_upgrade_time,
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
exclude_plugins=[],
include_plugins=[],
)
)
session.add(
TenantCreditPool(
tenant_id=tenant.id,
quota_limit=creation.trial_credits,
quota_used=0,
pool_type=ProviderQuotaType.TRIAL,
)
)
session.flush()
return CreatedWorkspace(
tenant.id,
tenant.name,
tenant.plan,
tenant.status.value,
tenant.created_at,
tenant.updated_at,
tenant.encrypt_public_key,
dict(tenant.custom_config_dict),
)
@override
def list_for_account(self, account_id: str) -> tuple[WorkspaceRecord, ...]:
stmt = (
select(
Tenant.id,
Tenant.name,
Tenant.status,
Tenant.created_at,
TenantAccountJoin.last_opened_at,
)
.join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id)
.where(
TenantAccountJoin.account_id == account_id,
Tenant.status == TenantStatus.NORMAL,
)
.order_by(Tenant.created_at.asc())
)
with self._session_factory() as session:
rows = session.execute(stmt).all()
return tuple(
WorkspaceRecord(
id=workspace_id,
name=name,
status=status.value,
created_at=created_at,
last_opened_at=last_opened_at,
)
for workspace_id, name, status, created_at, last_opened_at in rows
)
@override
def list_ids_for_account(self, account_id: str) -> tuple[str, ...]:
stmt = select(TenantAccountJoin.tenant_id).where(TenantAccountJoin.account_id == account_id)
with self._session_factory() as session:
return tuple(session.scalars(stmt).all())
@override
def list_account_access_workspaces(self, account_id: str) -> tuple[AccountWorkspaceSnapshot, ...]:
"""List every membership for the OpenAPI account identity response.
Unlike the Console workspace picker, the identity response preserves
its existing behavior of including archived memberships.
"""
stmt = (
select(
Tenant.id,
Tenant.name,
TenantAccountJoin.role,
TenantAccountJoin.current,
)
.join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id)
.where(TenantAccountJoin.account_id == account_id)
.order_by(Tenant.created_at.asc(), Tenant.id.asc())
)
with self._session_factory() as session:
return tuple(
AccountWorkspaceSnapshot(
id=workspace_id,
name=name,
role=role.value,
current=current,
)
for workspace_id, name, role, current in session.execute(stmt).all()
)
@override
def list_for_device_flow(self, account_id: str) -> tuple[DeviceWorkspace, ...]:
return tuple(
DeviceWorkspace(
id=workspace.id,
name=workspace.name,
role=workspace.role,
current=workspace.current,
)
for workspace in self.list_account_access_workspaces(account_id)
)
@override
def has_active_for_account(self, account_id: str) -> bool:
stmt = (
select(Tenant.id)
.join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id)
.where(
TenantAccountJoin.account_id == account_id,
Tenant.status == TenantStatus.NORMAL,
)
.limit(1)
)
with self._session_factory() as session:
return session.scalar(stmt) is not None
@override
def has_active_membership(self, account_id: str) -> bool:
return self.has_active_for_account(account_id)
@override
def list_all(self, *, page: int, limit: int) -> WorkspacePage:
with self._session_factory() as session:
result = paginate_query(
select(Tenant).order_by(Tenant.created_at.desc()), session=session, page=page, per_page=limit
)
return WorkspacePage(
data=tuple(self._snapshot(tenant) for tenant in result.items),
total=result.total,
page=result.page,
limit=result.per_page,
has_more=result.has_next,
)
@staticmethod
def _snapshot(
tenant: Tenant, role: TenantAccountRole | None = None, *, has_privileged_member: bool = False
) -> WorkspaceSnapshot:
config = tenant.custom_config_dict
return WorkspaceSnapshot(
id=tenant.id,
name=tenant.name,
status=tenant.status.value,
created_at=tenant.created_at,
role=role.value if role is not None else None,
custom_config=WorkspaceCustomConfig(
remove_webapp_brand=config.get("remove_webapp_brand", False),
replace_webapp_logo=config.get("replace_webapp_logo"),
),
has_privileged_member=has_privileged_member,
)
@classmethod
def _account_snapshot(cls, session: Session, tenant: Tenant, account_id: str) -> WorkspaceSnapshot:
role = session.scalar(
select(TenantAccountJoin.role).where(
TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account_id
)
)
if role is None:
raise WorkspaceNotFoundError()
return cls._snapshot(
tenant,
role,
has_privileged_member=session.scalar(
select(TenantAccountJoin.id)
.where(
TenantAccountJoin.tenant_id == tenant.id,
TenantAccountJoin.role.in_([TenantAccountRole.OWNER, TenantAccountRole.ADMIN]),
)
.limit(1)
)
is not None,
)
@override
def get_for_account(self, workspace_id: str, account_id: str) -> WorkspaceSnapshot | None:
with self._session_factory() as session:
tenant = session.get(Tenant, workspace_id)
return self._account_snapshot(session, tenant, account_id) if tenant is not None else None
@override
def switch(self, *, account_id: str, workspace_id: str) -> WorkspaceSnapshot:
with self._session_factory(expire_on_commit=False) as session:
account = session.get(Account, account_id)
if account is None:
raise WorkspaceNotLinkedError()
membership = session.scalar(
select(TenantAccountJoin)
.join(Tenant, Tenant.id == TenantAccountJoin.tenant_id)
.where(
TenantAccountJoin.account_id == account_id,
TenantAccountJoin.tenant_id == workspace_id,
Tenant.status == TenantStatus.NORMAL,
)
.limit(1)
)
if membership is None:
raise WorkspaceNotLinkedError()
session.execute(
update(TenantAccountJoin)
.where(TenantAccountJoin.account_id == account_id, TenantAccountJoin.tenant_id != workspace_id)
.values(current=False)
)
membership.current = True
membership.last_opened_at = naive_utc_now()
session.commit()
tenant = session.get(Tenant, workspace_id)
if tenant is None:
raise WorkspaceNotFoundError()
return replace(self._account_snapshot(session, tenant, account_id), current=True)
def get(self, workspace_id: str) -> WorkspaceSnapshot | None:
with self._session_factory() as session:
tenant = session.get(Tenant, workspace_id)
return self._snapshot(tenant) if tenant else None
def get_model(self, workspace_id: str) -> Tenant | None:
"""Materialize a workspace identity for admission adapters."""
with self._session_factory() as session:
return session.get(Tenant, workspace_id)
def get_many(self, workspace_ids: Sequence[str]) -> tuple[WorkspaceSnapshot, ...]:
if not workspace_ids:
return ()
with self._session_factory() as session:
return tuple(
self._snapshot(tenant) for tenant in session.scalars(select(Tenant).where(Tenant.id.in_(workspace_ids)))
)
def list_memberships(self, account_id: str) -> tuple[WorkspaceSnapshot, ...]:
with self._session_factory() as session:
rows = session.execute(
select(Tenant, TenantAccountJoin)
.join(TenantAccountJoin, Tenant.id == TenantAccountJoin.tenant_id)
.where(TenantAccountJoin.account_id == account_id)
.order_by(Tenant.created_at.asc())
)
return tuple(replace(self._snapshot(tenant, join.role), current=join.current) for tenant, join in rows)
def find_membership(self, account_id: str, workspace_id: str) -> WorkspaceSnapshot | None:
with self._session_factory() as session:
row = session.execute(
select(Tenant, TenantAccountJoin)
.join(TenantAccountJoin, Tenant.id == TenantAccountJoin.tenant_id)
.where(Tenant.id == workspace_id, TenantAccountJoin.account_id == account_id)
).first()
return replace(self._snapshot(row[0], row[1].role), current=row[1].current) if row else None
def member_role(self, workspace_id: str, account_id: str | None) -> TenantAccountRole | None:
if not account_id:
return None
with self._session_factory() as session:
return session.execute(
select(TenantAccountJoin.role).where(
TenantAccountJoin.tenant_id == workspace_id, TenantAccountJoin.account_id == account_id
)
).scalar_one_or_none()
def get_legacy_role(self, *, workspace_id: str, account_id: str) -> str | None:
"""Return one member's role without loading the whole workspace roster."""
role = self.member_role(workspace_id, account_id)
return role.value if role is not None else None
@override
def list_for_workspace(
self, workspace_id: str, *, role: TenantAccountRole | None = None
) -> tuple[WorkspaceMemberRecord, ...]:
"""Read membership snapshots without retaining a Session during role resolution."""
stmt = (
select(
Account.id,
Account.name,
Account.email,
Account.avatar,
Account.last_login_at,
Account.last_active_at,
Account.created_at,
Account.status,
TenantAccountJoin.role,
)
.select_from(Account)
.join(TenantAccountJoin, TenantAccountJoin.account_id == Account.id)
.where(TenantAccountJoin.tenant_id == workspace_id)
)
if role is not None:
stmt = stmt.where(TenantAccountJoin.role == role)
with self._session_factory() as session:
return tuple(
WorkspaceMemberRecord(
id=account_id,
name=name,
email=email,
avatar=avatar,
last_login_at=last_login_at,
last_active_at=last_active_at,
created_at=created_at,
status=status.value,
legacy_role=member_role.value,
)
for (
account_id,
name,
email,
avatar,
last_login_at,
last_active_at,
created_at,
status,
member_role,
) in session.execute(stmt).all()
)
def owner_id(self, workspace_id: str) -> str | None:
with self._session_factory() as session:
return session.scalar(
select(TenantAccountJoin.account_id)
.where(TenantAccountJoin.tenant_id == workspace_id, TenantAccountJoin.role == TenantAccountRole.OWNER)
.limit(1)
)
def upsert_member(self, *, workspace_id: str, account_id: str, role: TenantAccountRole) -> WorkspaceMemberWrite:
with self._session_factory.begin() as session:
account = session.get(Account, account_id)
if account is None:
raise MemberNotInTenantError("Member not in tenant.")
membership = session.scalar(
select(TenantAccountJoin)
.where(TenantAccountJoin.tenant_id == workspace_id, TenantAccountJoin.account_id == account_id)
.limit(1)
)
created = membership is None
if membership is None:
session.add(TenantAccountJoin(tenant_id=workspace_id, account_id=account_id, role=role))
else:
membership.role = role
return WorkspaceMemberWrite(workspace_id, account_id, role.value, account.status.value, created)
def remove_member(self, *, workspace_id: str, account_id: str, owner_id: str) -> WorkspaceMemberRemoval:
with self._session_factory.begin() as session:
account = session.get(Account, account_id)
membership = session.scalar(
select(TenantAccountJoin)
.where(TenantAccountJoin.tenant_id == workspace_id, TenantAccountJoin.account_id == account_id)
.limit(1)
)
if account is None or membership is None:
raise MemberNotInTenantError("Member not in tenant.")
session.execute(
update(App)
.where(App.tenant_id == workspace_id, App.maintainer == account_id)
.values(maintainer=owner_id)
)
session.execute(
update(Dataset)
.where(Dataset.tenant_id == workspace_id, Dataset.maintainer == account_id)
.values(maintainer=owner_id)
)
session.delete(membership)
delete_account = account.status == AccountStatus.PENDING and not session.scalar(
select(func.count(TenantAccountJoin.id)).where(TenantAccountJoin.account_id == account_id)
)
email = account.email
if delete_account:
session.delete(account)
return WorkspaceMemberRemoval(account_id, email, delete_account)
def update_member_role(self, *, workspace_id: str, account_id: str, role: TenantAccountRole) -> None:
with self._session_factory.begin() as session:
membership = session.scalar(
select(TenantAccountJoin)
.where(TenantAccountJoin.tenant_id == workspace_id, TenantAccountJoin.account_id == account_id)
.limit(1)
)
if membership is None:
raise MemberNotInTenantError("Member not in tenant.")
if role == TenantAccountRole.OWNER:
owner = session.scalar(
select(TenantAccountJoin)
.where(
TenantAccountJoin.tenant_id == workspace_id, TenantAccountJoin.role == TenantAccountRole.OWNER
)
.limit(1)
)
if owner:
owner.role = TenantAccountRole.NORMAL
membership.role = role
def list_member_ids(self, workspace_id: str, *, offset: int, limit: int) -> tuple[str, ...]:
with self._session_factory() as session:
return tuple(
session.scalars(
select(TenantAccountJoin.account_id)
.where(TenantAccountJoin.tenant_id == workspace_id)
.order_by(TenantAccountJoin.id)
.offset(offset)
.limit(limit)
)
)
def count_members(self, workspace_id: str) -> int:
with self._session_factory() as session:
return (
session.scalar(
select(func.count(TenantAccountJoin.id)).where(TenantAccountJoin.tenant_id == workspace_id)
)
or 0
)
@override
def rename(self, *, workspace_id: str, account_id: str, name: str) -> WorkspaceSnapshot:
with self._session_factory.begin() as session:
tenant = session.get(Tenant, workspace_id)
if tenant is None:
raise WorkspaceNotFoundError()
snapshot = self._account_snapshot(session, tenant, account_id)
tenant.name = name
return replace(snapshot, name=name)
@override
def update_custom_config(
self, *, workspace_id: str, account_id: str, changes: WorkspaceCustomConfig
) -> WorkspaceSnapshot:
with self._session_factory.begin() as session:
tenant = session.get(Tenant, workspace_id)
if tenant is None:
raise WorkspaceNotFoundError()
snapshot = self._account_snapshot(session, tenant, account_id)
tenant.custom_config_dict = {
"remove_webapp_brand": changes.remove_webapp_brand,
"replace_webapp_logo": changes.replace_webapp_logo,
}
return replace(snapshot, custom_config=changes)
@@ -1,76 +0,0 @@
"""Database repository for the workspace-member read model."""
from typing import override
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from models.account import Account, TenantAccountJoin
from services.workspace_member_query_service import WorkspaceMemberQuery, WorkspaceMemberRecord
class WorkspaceMemberQueryRepository(WorkspaceMemberQuery):
def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def list_for_workspace(self, workspace_id: str) -> tuple[WorkspaceMemberRecord, ...]:
stmt = (
select(
Account.id,
Account.name,
Account.email,
Account.avatar,
Account.last_login_at,
Account.last_active_at,
Account.created_at,
Account.status,
TenantAccountJoin.role,
)
.select_from(Account)
.join(TenantAccountJoin, TenantAccountJoin.account_id == Account.id)
.where(TenantAccountJoin.tenant_id == workspace_id)
)
with self._session_factory() as session:
rows = session.execute(stmt).all()
records = tuple(
WorkspaceMemberRecord(
id=account_id,
name=name,
email=email,
avatar=avatar,
last_login_at=last_login_at,
last_active_at=last_active_at,
created_at=created_at,
status=status.value,
legacy_role=legacy_role.value,
)
for (
account_id,
name,
email,
avatar,
last_login_at,
last_active_at,
created_at,
status,
legacy_role,
) in rows
)
return records
def get_legacy_role(self, *, workspace_id: str, account_id: str) -> str | None:
"""Return one member's role without loading the whole workspace roster."""
with self._session_factory() as session:
role = session.scalar(
select(TenantAccountJoin.role)
.where(
TenantAccountJoin.tenant_id == workspace_id,
TenantAccountJoin.account_id == account_id,
)
.limit(1)
)
return role.value if role is not None else None
@@ -1,132 +0,0 @@
"""Database repository for the workspace-list read model."""
from typing import override
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from models.account import Tenant, TenantAccountJoin, TenantStatus
from services.account_login_service import ConsoleAuthWorkspaceQuery
from services.account_ports import AccountWorkspaceMembershipQuery, AccountWorkspaceSnapshotQuery
from services.entities.account_access_entities import AccountWorkspaceSnapshot
from services.oauth_device_application_service import DeviceWorkspaceQuery
from services.oauth_device_contracts import DeviceWorkspace
from services.workspace_query_service import WorkspaceQuery, WorkspaceRecord
class WorkspaceQueryRepository(
WorkspaceQuery,
AccountWorkspaceMembershipQuery,
AccountWorkspaceSnapshotQuery,
ConsoleAuthWorkspaceQuery,
DeviceWorkspaceQuery,
):
def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
def get_account_role(self, *, account_id: str, tenant_id: str) -> str | None:
"""Read the current membership role without loading or mutating an Account."""
stmt = (
select(TenantAccountJoin.role)
.where(TenantAccountJoin.account_id == account_id, TenantAccountJoin.tenant_id == tenant_id)
.limit(1)
)
with self._session_factory() as session:
role = session.scalar(stmt)
return role.value if role is not None else None
@override
def list_for_account(self, account_id: str) -> tuple[WorkspaceRecord, ...]:
stmt = (
select(
Tenant.id,
Tenant.name,
Tenant.status,
Tenant.created_at,
TenantAccountJoin.last_opened_at,
)
.join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id)
.where(
TenantAccountJoin.account_id == account_id,
Tenant.status == TenantStatus.NORMAL,
)
.order_by(Tenant.created_at.asc())
)
with self._session_factory() as session:
rows = session.execute(stmt).all()
return tuple(
WorkspaceRecord(
id=workspace_id,
name=name,
status=status.value,
created_at=created_at,
last_opened_at=last_opened_at,
)
for workspace_id, name, status, created_at, last_opened_at in rows
)
@override
def list_ids_for_account(self, account_id: str) -> tuple[str, ...]:
stmt = select(TenantAccountJoin.tenant_id).where(TenantAccountJoin.account_id == account_id)
with self._session_factory() as session:
return tuple(session.scalars(stmt).all())
@override
def list_account_access_workspaces(self, account_id: str) -> tuple[AccountWorkspaceSnapshot, ...]:
"""List every membership for the OpenAPI account identity response.
Unlike the Console workspace picker, the identity response preserves
its existing behavior of including archived memberships.
"""
stmt = (
select(
Tenant.id,
Tenant.name,
TenantAccountJoin.role,
TenantAccountJoin.current,
)
.join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id)
.where(TenantAccountJoin.account_id == account_id)
.order_by(Tenant.created_at.asc(), Tenant.id.asc())
)
with self._session_factory() as session:
return tuple(
AccountWorkspaceSnapshot(
id=workspace_id,
name=name,
role=role.value,
current=current,
)
for workspace_id, name, role, current in session.execute(stmt).all()
)
@override
def list_for_device_flow(self, account_id: str) -> tuple[DeviceWorkspace, ...]:
return tuple(
DeviceWorkspace(
id=workspace.id,
name=workspace.name,
role=workspace.role,
current=workspace.current,
)
for workspace in self.list_account_access_workspaces(account_id)
)
@override
def has_active_for_account(self, account_id: str) -> bool:
stmt = (
select(Tenant.id)
.join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id)
.where(
TenantAccountJoin.account_id == account_id,
Tenant.status == TenantStatus.NORMAL,
)
.limit(1)
)
with self._session_factory() as session:
return session.scalar(stmt) is not None
@override
def has_active_membership(self, account_id: str) -> bool:
return self.has_active_for_account(account_id)
+1 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy.exc import SQLAlchemyError
import app
from configs import dify_config
from core.db.session_factory import session_factory
from core.rag.index_processor.index_processor_factory import IndexProcessorFactory
from core.rag.index_processor.index_processor import IndexProcessorFactory
from enums import CloudPlan
from extensions.ext_redis import redis_client
from libs.pagination import paginate_query
+1
View File
@@ -0,0 +1 @@
"""Account application services, shared contracts and infrastructure adapters."""
@@ -1,16 +1,28 @@
"""Infrastructure adapters shared by account application services."""
import logging
import secrets
import uuid
from collections.abc import Sequence
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from hashlib import sha256
from typing import override
from pydantic import TypeAdapter, ValidationError
from redis import RedisError
from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import Unauthorized
from configs import dify_config
from enums import DeploymentEdition
from extensions.ext_redis import RedisClientWrapper
from libs.datetime_utils import naive_utc_now
from libs.helper import RateLimiter, TokenManager
from libs.helper import timezone as validate_timezone
from libs.workspace_permission import check_workspace_member_invite_permission
from models.account import Account, AccountStatus
from repositories.account.repository import SQLAlchemyAccountRepository
from services.account.login_adapters import DeploymentConsoleAuthPolicyGateway
from services.account_activation_service import (
AccountActivationEligibility,
InvitationTokenStore,
@@ -38,6 +50,7 @@ from services.account_ports import (
from services.account_security_gateway import RedisAccountEmailSecurityGateway
from services.billing_service import BillingService
from services.enterprise.account_deletion_sync import sync_account_deletion_memberships
from services.enterprise.enterprise_service import try_join_default_workspace
from services.entities.account_activation_entities import InvitationLookup, InvitationToken
from services.entities.account_entities import (
AccountChangeEmailNewEmailToken,
@@ -59,10 +72,13 @@ from services.entities.auth_entities import (
ChangeEmailOldEmailVerifiedToken,
ChangeEmailTokenData,
)
from services.telemetry_service import CommunityTelemetryService
from tasks.delete_account_task import delete_account_task
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
from tasks.mail_change_mail_task import send_change_mail_completed_notification_task, send_change_mail_task
logger = logging.getLogger(__name__)
_invitation_token_adapter = TypeAdapter(InvitationToken)
_change_email_token_adapter: TypeAdapter[ChangeEmailTokenData] = TypeAdapter(ChangeEmailTokenData)
@@ -70,12 +86,88 @@ _CHANGE_EMAIL_RATE_LIMIT_ATTEMPTS = 1
_CHANGE_EMAIL_RATE_LIMIT_SECONDS = 60
_ACCOUNT_DELETION_RATE_LIMIT_ATTEMPTS = 1
_ACCOUNT_DELETION_RATE_LIMIT_SECONDS = 60
_ACCOUNT_LAST_ACTIVE_REFRESH_INTERVAL = timedelta(minutes=10)
class AccountIdentityGateway:
"""Materialize framework identities while the repositories own their Sessions."""
def __init__(
self,
*,
accounts: SQLAlchemyAccountRepository,
redis: RedisClientWrapper,
) -> None:
self._accounts = accounts
self._redis = redis
def get_account_by_id(self, account_id: str) -> Account | None:
return self._accounts.get_model(account_id)
def get_account_by_email(self, email: str, *, case_fallback: bool = False) -> Account | None:
return self._accounts.find_identity(email, case_fallback=case_fallback)
def load_user(self, account_id: str) -> Account | None:
now = naive_utc_now()
account = self._accounts.load_identity(account_id, now=now)
if account is None:
return None
if account.status == AccountStatus.BANNED:
raise Unauthorized("Account is banned.")
before = now - _ACCOUNT_LAST_ACTIVE_REFRESH_INTERVAL
if account.last_active_at < before:
try:
should_refresh = self._redis.set(
f"account_last_active_refresh:{account_id}",
1,
ex=int(_ACCOUNT_LAST_ACTIVE_REFRESH_INTERVAL.total_seconds()),
nx=True,
)
except RedisError:
should_refresh = True
if should_refresh:
self._accounts.touch_activity(account_id, now=now, before=before, touch_updated_at=True)
account.last_active_at = now
return account
class DeploymentAccountLifecyclePolicy(DeploymentConsoleAuthPolicyGateway):
def __init__(self) -> None:
super().__init__(billing_enabled=dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD)
def validate_timezone(self, timezone: str) -> str:
return validate_timezone(timezone)
def try_join_default_workspace(self, account_id: str) -> None:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
try_join_default_workspace(account_id)
class InstallationTelemetryGateway:
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
def report(self) -> None:
try:
with self._session_factory() as session:
CommunityTelemetryService.report_install(session=session)
except Exception:
logger.debug("Failed to report install telemetry", exc_info=True)
class RedisInvitationTokenStore(InvitationTokenStore):
def __init__(self, *, redis: RedisClientWrapper) -> None:
self._redis = redis
def create(self, invitation: InvitationToken) -> str:
token = str(uuid.uuid4())
self._redis.setex(
self._invitation_token_key(token),
dify_config.INVITE_EXPIRY_HOURS * 60 * 60,
_invitation_token_adapter.dump_json(invitation),
)
return token
@override
def find(self, invitation: InvitationLookup) -> InvitationToken | None:
if invitation.workspace_id is not None and invitation.email is not None:
+37
View File
@@ -0,0 +1,37 @@
"""Account lifecycle data shared by application and persistence boundaries."""
from dataclasses import dataclass, field
from datetime import datetime
from uuid import uuid4
from services.entities.account_entities import AccountPasswordDigest
@dataclass(frozen=True, slots=True)
class AccountCreation:
email: str
name: str
interface_language: str
interface_theme: str
timezone: str
password: AccountPasswordDigest | None = None
ip_address: str | None = None
check_normalized_email: bool = False
status: str = "active"
initialized_at: datetime | None = None
id: str = field(default_factory=lambda: str(uuid4()))
@dataclass(frozen=True, slots=True)
class SetupInput:
email: str
name: str
password: str
ip_address: str
language: str | None
@dataclass(frozen=True, slots=True)
class SetupStatus:
completed: bool
setup_at: datetime | None = None
@@ -1,16 +1,12 @@
"""Infrastructure adapters for account email registration."""
import logging
import secrets
from typing import override
from redis import RedisError
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from extensions.ext_redis import RedisClientWrapper
from libs.helper import RateLimiter, TokenManager
from models.account import Account
from services.account_email_registration_service import (
from services.account.email_registration_service import (
AccountRegistrationGateway,
AccountRegistrationPolicyGateway,
EmailRegistrationCodeGenerator,
@@ -19,27 +15,25 @@ from services.account_email_registration_service import (
EmailRegistrationSendLimiter,
EmailRegistrationTokenGateway,
)
from services.account.login_service import ConsoleAuthSecurityGateway
from services.account.service import AccountService
from services.account_errors import (
AccountEmailDomainSuspendedError,
AccountEmailFrozenError,
AccountNormalizedEmailAlreadyInUseError,
AccountRegisterError,
EmailRegistrationSeatsLimitError,
SeatsLimitExceededError,
)
from services.account_service import AccountService
from services.account_security_gateway import RedisAccountEmailSecurityGateway
from services.billing_service import BillingService
from services.entities.account_entities import (
AccountEmailRegistrationPhase,
AccountEmailRegistrationToken,
AccountSessionTokens,
)
from services.errors.account import (
AccountNormalizedEmailAlreadyInUseError as AccountNormalizedEmailAlreadyInUseServiceError,
)
from services.errors.account import AccountRegisterError, EmailDomainSuspendedError, SeatsLimitExceededError
from tasks.mail_register_task import send_email_register_mail_task, send_email_register_mail_task_when_account_exist
logger = logging.getLogger(__name__)
class TokenManagerEmailRegistrationTokenGateway(EmailRegistrationTokenGateway):
@override
@@ -115,56 +109,27 @@ class RateLimiterEmailRegistrationSendLimiter(EmailRegistrationSendLimiter):
return int(self._rate_limiter.time_window / 60)
class RedisEmailRegistrationSecurityGateway(EmailRegistrationSecurityGateway):
class RedisEmailRegistrationSecurityGateway(RedisAccountEmailSecurityGateway, EmailRegistrationSecurityGateway):
def __init__(
self,
*,
redis: RedisClientWrapper,
login_security: ConsoleAuthSecurityGateway,
verification_failure_limit: int,
verification_lockout_duration: int,
) -> None:
self._redis = redis
self._verification_failure_limit = verification_failure_limit
self._verification_lockout_duration = verification_lockout_duration
@override
def is_ip_limited(self, ip_address: str) -> bool:
return AccountService.is_email_send_ip_limit(ip_address) is True
@override
def is_verification_limited(self, email: str) -> bool:
try:
count = self._redis.get(self._verification_key(email))
return count is not None and int(count) > self._verification_failure_limit
except RedisError:
logger.warning("Failed to read email-registration verification limit", exc_info=True)
return False
@override
def record_verification_failure(self, email: str) -> None:
try:
key = self._verification_key(email)
count = int(self._redis.get(key) or 0) + 1
self._redis.setex(key, self._verification_lockout_duration, count)
except RedisError:
logger.warning("Failed to record email-registration verification failure", exc_info=True)
return None
@override
def reset_verification_failures(self, email: str) -> None:
try:
self._redis.delete(self._verification_key(email))
except RedisError:
logger.warning("Failed to reset email-registration verification failures", exc_info=True)
return None
super().__init__(
redis=redis,
email_send_ip_limit_per_minute=dify_config.EMAIL_SEND_IP_LIMIT_PER_MINUTE,
verification_failure_limit=verification_failure_limit,
verification_lockout_duration=verification_lockout_duration,
verification_key_prefix="email_register_error_rate_limit",
)
self._login_security = login_security
@override
def reset_login_failures(self, email: str) -> None:
AccountService.reset_login_error_rate_limit(email)
@staticmethod
def _verification_key(email: str) -> str:
return f"email_register_error_rate_limit:{email}"
self._login_security.reset_login_failures(email)
class BillingAccountRegistrationPolicyGateway(AccountRegistrationPolicyGateway):
@@ -178,53 +143,32 @@ class BillingAccountRegistrationPolicyGateway(AccountRegistrationPolicyGateway):
return BillingService.get_email_freeze_type(email)
class AccountServiceRegistrationGateway(AccountRegistrationGateway):
"""Compatibility adapter around account provisioning and login internals."""
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
class AccountLifecycleRegistrationGateway(AccountRegistrationGateway):
def __init__(self, *, accounts: AccountService) -> None:
self._accounts = accounts
@override
def create(
self,
*,
email: str,
password: str,
interface_language: str,
timezone: str | None,
ip_address: str,
self, *, email: str, password: str, interface_language: str, timezone: str | None, ip_address: str
) -> str:
with self._session_factory() as session:
try:
account = AccountService.create_account_and_tenant(
email=email,
name=email,
password=password,
interface_language=interface_language,
timezone=timezone,
ip_address=ip_address,
check_normalized_email=True,
session=session,
)
except SeatsLimitExceededError as exc:
raise EmailRegistrationSeatsLimitError from exc
except EmailDomainSuspendedError as exc:
raise AccountEmailDomainSuspendedError from exc
except AccountNormalizedEmailAlreadyInUseServiceError as exc:
raise AccountNormalizedEmailAlreadyInUseError from exc
except AccountRegisterError as exc:
raise AccountEmailFrozenError from exc
return account.id
try:
account = self._accounts.create_account_and_tenant(
email=email,
name=email,
password=password,
interface_language=interface_language,
timezone=timezone,
ip_address=ip_address,
check_normalized_email=True,
)
except SeatsLimitExceededError as exc:
raise EmailRegistrationSeatsLimitError from exc
except (AccountEmailDomainSuspendedError, AccountNormalizedEmailAlreadyInUseError):
raise
except AccountRegisterError as exc:
raise AccountEmailFrozenError from exc
return account.id
@override
def login(self, account_id: str, *, ip_address: str) -> AccountSessionTokens:
with self._session_factory() as session:
account = session.get(Account, account_id)
if account is None:
raise RuntimeError("newly registered account no longer exists")
token_pair = AccountService.login(account=account, session=session, ip_address=ip_address)
return AccountSessionTokens(
access_token=token_pair.access_token,
refresh_token=token_pair.refresh_token,
csrf_token=token_pair.csrf_token,
)
return self._accounts.login(account_id, ip_address=ip_address)
@@ -9,7 +9,7 @@ from pydantic import TypeAdapter, ValidationError
from extensions.ext_redis import RedisClientWrapper
from extensions.redis_names import serialize_redis_name
from libs.helper import RateLimiter
from services.account_forgot_password_service import (
from services.account.forgot_password_service import (
FORGOT_PASSWORD_SEND_RATE_LIMIT_MAX_ATTEMPTS,
FORGOT_PASSWORD_SEND_RATE_LIMIT_PREFIX,
FORGOT_PASSWORD_SEND_RATE_LIMIT_WINDOW_SECONDS,
@@ -104,12 +104,15 @@ class AccountForgotPasswordService:
email: str,
language: str,
ip_address: str,
require_account: bool = False,
) -> str:
if self._security.is_ip_limited(ip_address):
raise ForgotPasswordSendIPLimitedError
normalized_email = email.lower()
account = self._accounts.find_by_email(email)
if require_account and account is None:
raise AccountNotFoundError
destination = account.email if account is not None else normalized_email
registration_allowed = self._registration.is_registration_allowed()
if self._send_limits.is_limited(destination):
@@ -5,37 +5,18 @@ import secrets
from datetime import UTC, datetime, timedelta
from typing import override
from redis import RedisError
from sqlalchemy import select, update
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from enums import DeploymentEdition
from events.tenant_event import tenant_was_created
from extensions.ext_redis import RedisClientWrapper
from libs.datetime_utils import naive_utc_now
from libs.helper import RateLimiter, TokenManager
from libs.key_providers import generate_key_pair
from libs.passport import PassportService
from libs.token import generate_csrf_token
from models import TenantCreditPool
from models.account import (
Account,
AccountStatus,
Tenant,
TenantAccountJoin,
TenantAccountRole,
TenantPluginAutoUpgradeCategory,
TenantPluginAutoUpgradeMode,
TenantPluginAutoUpgradeStrategy,
TenantStatus,
)
from models.enums import ProviderQuotaType
from repositories.account.repository import SQLAlchemyAccountRepository
from services import account_errors
from services.account_activation_service import AccountActivationRepository, InvitationTokenStore
from services.account_email import normalize_email
from services.account_login_service import (
AccountProvisioningGateway,
from services.account.login_service import (
AccountRefreshPreparationGateway,
AccountSessionGateway,
ConsoleAuthAuditGateway,
@@ -45,20 +26,15 @@ from services.account_login_service import (
EmailCodeGateway,
HumanVerificationGateway,
ResetPasswordEmailGateway,
WorkspaceProvisioningGateway,
)
from services.account_activation_service import AccountActivationRepository, InvitationTokenStore
from services.account_security_gateway import RedisAccountEmailSecurityGateway
from services.billing_service import BillingService
from services.email_code_login_challenge import EmailCodeLoginChallengeStore, EmailCodeLoginChallengeUnavailableError
from services.enterprise.rbac_service import ListOption, RBACService
from services.entities.account_activation_entities import InvitationLookup
from services.entities.account_login_entities import (
AuthTokenPair,
EmailCodeChallengeStatus,
LoginInvitation,
RefreshAccountStatus,
)
from services.entities.account_entities import AccountSessionTokens
from services.entities.account_login_entities import EmailCodeChallengeStatus, LoginInvitation, RefreshAccountStatus
from services.entities.auth_audit_entities import LoginFailureReason
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.system_feature_service import SystemFeatureService
from services.turnstile_service import (
EMAIL_CODE_SEND_ACTION,
@@ -80,7 +56,6 @@ _ACCOUNT_REFRESH_TOKEN_PREFIX = "account_refresh_token:"
_EMAIL_CODE_LOGIN_RATE_LIMIT_ATTEMPTS = 3
_EMAIL_CODE_LOGIN_RATE_LIMIT_SECONDS = 5 * 60
_LOGIN_FAILURE_LIMIT = 5
_LOGIN_FAILURE_PREFIX = "login_error_rate_limit:"
_REFRESH_TOKEN_PREFIX = "refresh_token:"
_RESET_PASSWORD_RATE_LIMIT_ATTEMPTS = 1
_RESET_PASSWORD_RATE_LIMIT_SECONDS = 60
@@ -135,58 +110,31 @@ class DeploymentConsoleAuthPolicyGateway(ConsoleAuthPolicyGateway):
return SystemFeatureService.get_license().seats.is_available()
class RedisConsoleAuthSecurityGateway(ConsoleAuthSecurityGateway):
class RedisConsoleAuthSecurityGateway(RedisAccountEmailSecurityGateway, ConsoleAuthSecurityGateway):
def __init__(self, *, redis: RedisClientWrapper) -> None:
self._redis = redis
super().__init__(
redis=redis,
email_send_ip_limit_per_minute=dify_config.EMAIL_SEND_IP_LIMIT_PER_MINUTE,
verification_failure_limit=_LOGIN_FAILURE_LIMIT,
verification_lockout_duration=dify_config.LOGIN_LOCKOUT_DURATION,
verification_key_prefix="login_error_rate_limit",
)
@override
def is_login_limited(self, email: str) -> bool:
try:
count = self._redis.get(f"{_LOGIN_FAILURE_PREFIX}{email}")
return count is not None and int(count) > _LOGIN_FAILURE_LIMIT
except RedisError:
return False
return self.is_verification_limited(email)
@override
def record_login_failure(self, email: str) -> None:
try:
key = f"{_LOGIN_FAILURE_PREFIX}{email}"
count = int(self._redis.get(key) or 0) + 1
self._redis.setex(key, dify_config.LOGIN_LOCKOUT_DURATION, count)
except RedisError:
return None
self.record_verification_failure(email)
@override
def reset_login_failures(self, email: str) -> None:
try:
self._redis.delete(f"{_LOGIN_FAILURE_PREFIX}{email}")
except RedisError:
return None
self.reset_verification_failures(email)
@override
def is_email_send_ip_limited(self, ip_address: str) -> bool:
minute_key = f"email_send_ip_limit_minute:{ip_address}"
freeze_key = f"email_send_ip_limit_freeze:{ip_address}"
hour_limit_key = f"email_send_ip_limit_hour:{ip_address}"
try:
if self._redis.get(freeze_key):
return True
current_minute_count = int(self._redis.get(minute_key) or 0)
if current_minute_count > dify_config.EMAIL_SEND_IP_LIMIT_PER_MINUTE:
hour_limit_count = int(self._redis.get(hour_limit_key) or 0)
if hour_limit_count >= 1:
self._redis.setex(freeze_key, 60 * 60, 1)
return True
if not self._redis.set(hour_limit_key, 1, ex=60 * 10, nx=True):
self._redis.setex(freeze_key, 60 * 60, 1)
return True
self._redis.setex(minute_key, 60, current_minute_count + 1)
self._redis.expire(minute_key, 60)
return False
except RedisError:
return False
return self.is_ip_limited(ip_address)
class TurnstileHumanVerificationGateway(HumanVerificationGateway):
@@ -212,7 +160,7 @@ class RedisAccountSessionGateway(AccountSessionGateway):
self._redis = redis
@override
def issue(self, account_id: str) -> AuthTokenPair:
def issue(self, account_id: str) -> AccountSessionTokens:
return self._issue(account_id)
@override
@@ -227,11 +175,11 @@ class RedisAccountSessionGateway(AccountSessionGateway):
return account_id.decode("utf-8") if account_id else None
@override
def rotate(self, *, refresh_token: str, account_id: str) -> AuthTokenPair:
def rotate(self, *, refresh_token: str, account_id: str) -> AccountSessionTokens:
self._delete_refresh_token(refresh_token, account_id)
return self._issue(account_id)
def _issue(self, account_id: str) -> AuthTokenPair:
def _issue(self, account_id: str) -> AccountSessionTokens:
expires_at = datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = PassportService().issue(
{
@@ -243,7 +191,7 @@ class RedisAccountSessionGateway(AccountSessionGateway):
)
refresh_token = secrets.token_hex(64)
self._store_refresh_token(refresh_token, account_id)
return AuthTokenPair(
return AccountSessionTokens(
access_token=access_token,
refresh_token=refresh_token,
csrf_token=generate_csrf_token(account_id),
@@ -268,187 +216,24 @@ class RedisAccountSessionGateway(AccountSessionGateway):
class SQLAlchemyAccountRefreshPreparationGateway(AccountRefreshPreparationGateway):
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
def __init__(self, *, accounts: SQLAlchemyAccountRepository) -> None:
self._accounts = accounts
@override
def prepare(self, account_id: str) -> RefreshAccountStatus:
now = naive_utc_now()
with self._session_factory.begin() as session:
account = session.get(Account, account_id)
if account is None:
return RefreshAccountStatus.NOT_FOUND
if account.status == AccountStatus.BANNED:
return RefreshAccountStatus.BANNED
current_membership = session.scalar(
select(TenantAccountJoin)
.join(Tenant, Tenant.id == TenantAccountJoin.tenant_id)
.where(
TenantAccountJoin.account_id == account_id,
TenantAccountJoin.current.is_(True),
Tenant.status == TenantStatus.NORMAL,
)
.limit(1)
)
if current_membership is None:
session.execute(
update(TenantAccountJoin)
.where(TenantAccountJoin.account_id == account_id, TenantAccountJoin.current.is_(True))
.values(current=False)
)
current_membership = session.scalar(
select(TenantAccountJoin)
.join(Tenant, Tenant.id == TenantAccountJoin.tenant_id)
.where(
TenantAccountJoin.account_id == account_id,
Tenant.status == TenantStatus.NORMAL,
)
.order_by(TenantAccountJoin.id.asc())
.limit(1)
)
if current_membership is None:
return RefreshAccountStatus.NOT_FOUND
current_membership.current = True
current_membership.last_opened_at = now
session.execute(
update(Account)
.where(
Account.id == account_id,
Account.last_active_at < now - _ACCOUNT_LAST_ACTIVE_REFRESH_INTERVAL,
)
.values(last_active_at=now)
)
return RefreshAccountStatus.READY
class SQLAlchemyConsoleAuthProvisioningGateway(AccountProvisioningGateway, WorkspaceProvisioningGateway):
"""Persist the account/workspace aggregate and own its transaction boundary."""
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def create_with_owner_workspace(
self,
*,
email: str,
name: str,
interface_language: str,
timezone: str,
ip_address: str,
) -> str:
normalized_email = normalize_email(email)
account = Account(
name=name,
email=email,
normalized_email=normalized_email,
interface_language=interface_language,
interface_theme="light",
timezone=timezone,
last_login_ip=ip_address,
)
with self._session_factory.begin() as session:
normalized_email_in_use = session.scalar(
select(Account.id).where(Account.normalized_email == normalized_email).limit(1)
)
if normalized_email_in_use is not None:
raise account_errors.AccountNormalizedEmailAlreadyInUseError
session.add(account)
tenant = self._add_owner_workspace(session, account)
self._bind_owner_rbac_role(tenant, account.id, session)
self._after_workspace_created(tenant, account.id)
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
from services.enterprise.enterprise_service import try_join_default_workspace
try_join_default_workspace(account.id)
return account.id
@override
def create_owner_workspace(self, account_id: str) -> None:
with self._session_factory.begin() as session:
account = session.scalar(select(Account).where(Account.id == account_id).with_for_update())
if account is None:
raise account_errors.AccountNotFoundError
existing_workspace_id = session.scalar(
select(Tenant.id)
.join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id)
.where(
TenantAccountJoin.account_id == account_id,
Tenant.status == TenantStatus.NORMAL,
)
.limit(1)
)
if existing_workspace_id is not None:
return
tenant = self._add_owner_workspace(session, account)
self._bind_owner_rbac_role(tenant, account.id, session)
self._after_workspace_created(tenant, account.id)
@staticmethod
def _add_owner_workspace(session: Session, account: Account) -> Tenant:
tenant = Tenant(name=f"{account.name}'s Workspace")
tenant.encrypt_public_key = generate_key_pair(tenant.id)
session.add(tenant)
session.add(
TenantAccountJoin(
tenant_id=tenant.id,
account_id=account.id,
role=TenantAccountRole.OWNER,
)
)
for category in TenantPluginAutoUpgradeCategory:
session.add(
TenantPluginAutoUpgradeStrategy(
tenant_id=tenant.id,
category=category,
strategy_setting=PluginAutoUpgradeService.default_strategy_setting_for_category(category),
upgrade_time_of_day=PluginAutoUpgradeService.default_upgrade_time_of_day(tenant.id),
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
exclude_plugins=[],
include_plugins=[],
)
)
session.add(
TenantCreditPool(
tenant_id=tenant.id,
quota_limit=dify_config.HOSTED_POOL_CREDITS,
quota_used=0,
pool_type=ProviderQuotaType.TRIAL,
)
)
return tenant
def _bind_owner_rbac_role(self, tenant: Tenant, account_id: str, session: Session) -> None:
if dify_config.RBAC_ENABLED:
owner_role_id = self._resolve_owner_role_id(tenant.id, account_id)
RBACService.MemberRoles.replace(
tenant_id=tenant.id,
account_id=account_id,
member_account_id=account_id,
role_ids=[owner_role_id],
session=session,
)
def _after_workspace_created(self, tenant: Tenant, account_id: str) -> None:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
BillingService.clean_billing_info_cache(tenant.id)
tenant_was_created.send(tenant)
@staticmethod
def _resolve_owner_role_id(tenant_id: str, account_id: str) -> str:
roles = RBACService.Roles.list(
tenant_id,
account = self._accounts.load_identity(account_id, now=now)
if account is None:
return RefreshAccountStatus.NOT_FOUND
if account.status == AccountStatus.BANNED:
return RefreshAccountStatus.BANNED
self._accounts.touch_activity(
account_id,
options=ListOption(page_number=1, results_per_page=100),
).data
for role in roles:
if role.is_builtin and role.category == "global_system_default" and role.role_tag == "owner":
return role.id
raise ValueError(f"Builtin RBAC owner role not found in tenant {tenant_id}")
now=now,
before=now - _ACCOUNT_LAST_ACTIVE_REFRESH_INTERVAL,
touch_updated_at=False,
)
return RefreshAccountStatus.READY
class RedisEmailCodeGateway(EmailCodeGateway):
@@ -7,9 +7,9 @@ from typing import Never, Protocol
from constants.languages import get_valid_language, language_timezone_mapping
from services import account_errors
from services.account_ports import AccountPasswordHasher
from services.entities.account_entities import AccountSessionTokens
from services.entities.account_login_entities import (
AccountSessionPreparation,
AuthTokenPair,
EmailCodeChallengeStatus,
EmailCodeLoginCommand,
EmailCodeSendCommand,
@@ -69,13 +69,13 @@ class HumanVerificationGateway(Protocol):
class AccountSessionGateway(Protocol):
def issue(self, account_id: str) -> AuthTokenPair: ...
def issue(self, account_id: str) -> AccountSessionTokens: ...
def revoke(self, account_id: str) -> None: ...
def resolve_refresh_token(self, refresh_token: str) -> str | None: ...
def rotate(self, *, refresh_token: str, account_id: str) -> AuthTokenPair: ...
def rotate(self, *, refresh_token: str, account_id: str) -> AccountSessionTokens: ...
class AccountRefreshPreparationGateway(Protocol):
@@ -95,7 +95,7 @@ class AccountProvisioningGateway(Protocol):
class WorkspaceProvisioningGateway(Protocol):
def create_owner_workspace(self, account_id: str) -> None: ...
def ensure_owner_workspace(self, account_id: str) -> None: ...
class EmailCodeGateway(Protocol):
@@ -201,15 +201,15 @@ class ConsoleAuthenticationService:
if not self._workspaces.has_active_for_account(account.id):
if self._policies.is_workspace_creation_allowed() and not self._policies.has_workspace_capacity():
raise account_errors.LoginWorkspaceLimitError
return PasswordLoginResult(token_pair=None, workspace_found=False)
return PasswordLoginResult(tokens=None, workspace_found=False)
token_pair = self._issue_session(
tokens = self._issue_session(
account_id=account.id,
ip_address=command.ip_address,
activate_pending_account=False,
)
self._security.reset_login_failures(normalized_email)
return PasswordLoginResult(token_pair=token_pair, workspace_found=True)
return PasswordLoginResult(tokens=tokens, workspace_found=True)
def logout(self, account_id: str) -> None:
self._sessions.revoke(account_id)
@@ -250,7 +250,7 @@ class ConsoleAuthenticationService:
language=self._email_language(command.language),
)
def login_with_email_code(self, command: EmailCodeLoginCommand) -> AuthTokenPair:
def login_with_email_code(self, command: EmailCodeLoginCommand) -> AccountSessionTokens:
normalized_email = command.email.lower()
if not self._is_valid_email_code(command.code):
self._fail(
@@ -279,7 +279,7 @@ class ConsoleAuthenticationService:
raise account_errors.LoginWorkspaceLimitError
if not self._policies.is_workspace_creation_allowed():
raise account_errors.LoginWorkspaceCreationNotAllowedError
self._workspace_provisioning.create_owner_workspace(account.id)
self._workspace_provisioning.ensure_owner_workspace(account.id)
account_id = account.id
else:
if not self._policies.is_registration_allowed():
@@ -299,15 +299,15 @@ class ConsoleAuthenticationService:
ip_address=command.ip_address,
)
token_pair = self._issue_session(
tokens = self._issue_session(
account_id=account_id,
ip_address=command.ip_address,
activate_pending_account=account is not None and account.status == "pending",
)
self._security.reset_login_failures(normalized_email)
return token_pair
return tokens
def refresh(self, refresh_token: str) -> AuthTokenPair:
def refresh(self, refresh_token: str) -> AccountSessionTokens:
account_id = self._sessions.resolve_refresh_token(refresh_token)
if account_id is None:
raise account_errors.InvalidRefreshTokenError("Invalid refresh token")
@@ -362,7 +362,7 @@ class ConsoleAuthenticationService:
account_id: str,
ip_address: str,
activate_pending_account: bool,
) -> AuthTokenPair:
) -> AccountSessionTokens:
prepared = self._accounts.prepare_session(
account_id,
AccountSessionPreparation(
@@ -13,21 +13,45 @@ from redis.exceptions import LockError
from extensions.ext_redis import RedisClientWrapper
from libs.oauth import OAuth
from services.account.oauth_service import (
OAuthAccountClaimLease,
OAuthAccountClaimLock,
OAuthAccountRegistrationGateway,
OAuthInvitationGateway,
OAuthProviderGateway,
OAuthRegistrationPolicyGateway,
OAuthSessionGateway,
OAuthWorkspaceGateway,
OAuthWorkspacePolicyGateway,
)
from services.account.service import AccountService
from services.account_activation_service import AccountActivationRepository, InvitationTokenStore
from services.account_errors import (
AccountEmailDomainSuspendedError,
AccountNotFoundError,
AccountRegisterError,
OAuthAccountNotFoundError,
OAuthIdentityLockUnavailableError,
OAuthProviderAuthorizationError,
OAuthProviderRequestError,
)
from services.account_oauth_service import (
OAuthAccountClaimLease,
OAuthAccountClaimLock,
OAuthProviderGateway,
OAuthRegistrationPolicyGateway,
OAuthWorkspacePolicyGateway,
OAuthRegistrationError,
OAuthSeatsLimitExceededError,
OAuthWorkspaceCreationNotAllowedError,
SeatsLimitExceededError,
)
from services.billing_service import BillingService
from services.entities.account_oauth_entities import OAuthAuthorizationRequest, OAuthIdentity
from services.enterprise.enterprise_service import try_join_default_workspace
from services.entities.account_activation_entities import InvitationLookup
from services.entities.account_entities import AccountSessionTokens
from services.entities.account_oauth_entities import (
OAuthAccountRegistration,
OAuthAuthorizationRequest,
OAuthIdentity,
OAuthInvitation,
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.system_feature_service import SystemFeatureService
from services.workspace.provisioning_service import WorkspaceProvisioningService
logger = logging.getLogger(__name__)
@@ -201,3 +225,73 @@ class DeploymentOAuthPolicyGateway(OAuthRegistrationPolicyGateway, OAuthWorkspac
@override
def is_creation_allowed(self) -> bool:
return SystemFeatureService.is_workspace_creation_allowed()
class AccountActivationOAuthInvitationGateway(OAuthInvitationGateway):
def __init__(self, *, tokens: InvitationTokenStore, accounts: AccountActivationRepository) -> None:
self._tokens = tokens
self._accounts = accounts
def resolve(self, invite_token: str) -> OAuthInvitation | None:
token = self._tokens.find(InvitationLookup(workspace_id=None, email=None, token=invite_token))
if token is None:
return None
invitation = self._accounts.resolve(token)
if invitation is None:
return None
return OAuthInvitation(invitation.account_id, invitation.account_email, invitation.account_status)
class AccountLifecycleOAuthRegistrationGateway(OAuthAccountRegistrationGateway):
def __init__(self, *, accounts: AccountService) -> None:
self._accounts = accounts
def register(self, registration: OAuthAccountRegistration) -> str:
try:
account = self._accounts.create_account(
email=registration.email,
name=registration.name,
interface_language=registration.language,
password=None,
timezone=registration.timezone,
ip_address=registration.ip_address,
check_normalized_email=True,
status="active",
initialized=True,
)
except AccountEmailDomainSuspendedError:
raise
except SeatsLimitExceededError as exc:
raise OAuthSeatsLimitExceededError from exc
except AccountRegisterError as exc:
raise OAuthRegistrationError(exc.description) from exc
except Exception as exc:
raise OAuthRegistrationError(f"Registration failed: {exc}") from exc
return account.id
class WorkspaceProvisioningOAuthGateway(OAuthWorkspaceGateway):
def __init__(self, *, workspaces: WorkspaceProvisioningService) -> None:
self._workspaces = workspaces
def create_owner_workspace(self, account_id: str) -> None:
try:
self._workspaces.create_owner_workspace(account_id)
except AccountNotFoundError as exc:
raise OAuthAccountNotFoundError from exc
except (WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError) as exc:
raise OAuthWorkspaceCreationNotAllowedError from exc
def try_join_default_workspace(self, account_id: str) -> None:
try_join_default_workspace(account_id)
class AccountLifecycleOAuthSessionGateway(OAuthSessionGateway):
def __init__(self, *, accounts: AccountService) -> None:
self._accounts = accounts
def login(self, account_id: str, *, ip_address: str) -> AccountSessionTokens:
try:
return self._accounts.login(account_id, ip_address=ip_address)
except AccountNotFoundError as exc:
raise OAuthAccountNotFoundError from exc
File renamed without changes.
+292
View File
@@ -0,0 +1,292 @@
"""Account creation, registration and session use cases, independent of ORM and HTTP."""
from collections.abc import Callable
from datetime import datetime
from typing import Protocol
from constants.languages import get_valid_language, language_timezone_mapping
from services.account.contracts import AccountCreation, SetupInput
from services.account.login_service import AccountSessionGateway, ConsoleAuthSecurityGateway
from services.account_errors import (
AccountEmailDomainSuspendedError,
AccountLoginError,
AccountNormalizedEmailAlreadyInUseError,
AccountNotFoundError,
AccountPasswordError,
AccountRegisterError,
SeatsLimitExceededError,
)
from services.account_ports import AccountPasswordHasher
from services.entities.account_entities import (
AccountCredentials,
AccountPasswordDigest,
AccountSessionTokens,
AccountSnapshot,
)
from services.entities.account_login_entities import AccountSessionPreparation
from services.errors.workspace import WorkSpaceNotAllowedCreateError
class AccountLifecycleStore(Protocol):
def get(self, account_id: str) -> AccountSnapshot | None: ...
def find_by_email(self, email: str) -> AccountSnapshot | None: ...
def normalized_email_exists(self, email: str) -> bool: ...
def create(self, creation: AccountCreation) -> AccountSnapshot: ...
def prepare_session(self, account_id: str, preparation: AccountSessionPreparation) -> bool: ...
def get_credentials(self, account_id: str) -> AccountCredentials | None: ...
def update_password(self, account_id: str, password: AccountPasswordDigest) -> AccountSnapshot | None: ...
def set_email(self, account_id: str, email: str) -> None: ...
class AccountLifecyclePolicy(Protocol):
def is_registration_allowed(self) -> bool: ...
def has_account_capacity(self) -> bool: ...
def get_email_freeze_type(self, email: str) -> str | None: ...
def is_workspace_creation_allowed(self) -> bool: ...
def has_workspace_capacity(self) -> bool: ...
def validate_timezone(self, timezone: str) -> str: ...
def try_join_default_workspace(self, account_id: str) -> None: ...
class AccountWorkspaceProvisioner(Protocol):
def create_owner_workspace(
self, account_id: str, *, name: str | None = None, is_setup: bool = False, if_missing: bool = False
) -> None: ...
class AccountService:
def __init__(
self,
*,
accounts: AccountLifecycleStore,
policies: AccountLifecyclePolicy,
passwords: AccountPasswordHasher,
workspaces: AccountWorkspaceProvisioner,
sessions: AccountSessionGateway,
security: ConsoleAuthSecurityGateway,
now: Callable[[], datetime],
) -> None:
self._accounts = accounts
self._policies = policies
self._passwords = passwords
self._workspaces = workspaces
self._sessions = sessions
self._security = security
self._now = now
def get_account_by_id(self, account_id: str) -> AccountSnapshot | None:
return self._accounts.get(account_id)
def get_account_by_email_with_case_fallback(self, email: str) -> AccountSnapshot | None:
return self._accounts.find_by_email(email)
def authenticate(self, email: str, password: str) -> AccountSnapshot:
account = self.get_account_by_email_with_case_fallback(email)
if account is None:
raise AccountNotFoundError()
if account.status == "banned":
raise AccountLoginError("Account is banned.")
credentials = self._accounts.get_credentials(account.id)
if (
not credentials
or not credentials.password_hash
or not credentials.password_salt
or not self._passwords.verify(
password, password_hash=credentials.password_hash, password_salt=credentials.password_salt
)
):
raise AccountPasswordError("Invalid email or password.")
return account
def reset_login_failures(self, email: str) -> None:
self._security.reset_login_failures(email)
def reset_password(self, account_id: str, password: str, *, email: str) -> None:
if self._accounts.update_password(account_id, self._passwords.hash(password)) is None:
raise AccountNotFoundError()
self.reset_login_failures(email)
def set_email(self, account_id: str, email: str) -> None:
self._accounts.set_email(account_id, email)
def create_account(
self,
email: str,
name: str,
interface_language: str,
password: str | None = None,
interface_theme: str = "light",
is_setup: bool = False,
timezone: str | None = None,
ip_address: str | None = None,
check_normalized_email: bool = False,
*,
status: str = "active",
initialized: bool = False,
) -> AccountSnapshot:
if not self._policies.is_registration_allowed() and not is_setup:
raise AccountNotFoundError("Account registration is disabled.")
if check_normalized_email and self._accounts.normalized_email_exists(email):
raise AccountNormalizedEmailAlreadyInUseError("An account with an equivalent email already exists.")
if not self._policies.has_account_capacity():
raise SeatsLimitExceededError("licensed seats limit exceeded")
freeze_type = self._policies.get_email_freeze_type(email)
if freeze_type == "email_domain_suspended":
raise AccountEmailDomainSuspendedError()
if freeze_type:
raise AccountRegisterError(
"This email account has been deleted within the past "
"30 days and is temporarily unavailable for new account registration"
)
resolved_timezone = (
self._policies.validate_timezone(timezone)
if timezone is not None
else language_timezone_mapping.get(interface_language, "UTC")
)
return self._accounts.create(
AccountCreation(
email=email,
name=name,
interface_language=interface_language,
interface_theme=interface_theme,
timezone=resolved_timezone,
password=self._passwords.hash(password) if password else None,
ip_address=ip_address,
check_normalized_email=check_normalized_email,
status=status,
initialized_at=self._now() if initialized else None,
)
)
def create_account_and_tenant(
self,
email: str,
name: str,
interface_language: str,
password: str | None = None,
timezone: str | None = None,
ip_address: str | None = None,
check_normalized_email: bool = False,
) -> AccountSnapshot:
account = self.create_account(
email=email,
name=name,
interface_language=interface_language,
password=password,
timezone=timezone,
ip_address=ip_address,
check_normalized_email=check_normalized_email,
)
try:
self._workspaces.create_owner_workspace(account.id, if_missing=True)
finally:
self._policies.try_join_default_workspace(account.id)
return account
def register(
self,
email: str,
name: str,
password: str | None = None,
language: str | None = None,
status: str | None = None,
is_setup: bool = False,
create_workspace_required: bool = True,
timezone: str | None = None,
ip_address: str | None = None,
check_normalized_email: bool = True,
) -> AccountSnapshot:
try:
account = self.create_account(
email=email,
name=name,
interface_language=get_valid_language(language),
password=password,
is_setup=is_setup,
timezone=timezone,
ip_address=ip_address,
check_normalized_email=check_normalized_email,
status=status or "active",
initialized=True,
)
try:
if (
create_workspace_required
and self._policies.is_workspace_creation_allowed()
and self._policies.has_workspace_capacity()
):
self._workspaces.create_owner_workspace(account.id)
finally:
self._policies.try_join_default_workspace(account.id)
return account
except WorkSpaceNotAllowedCreateError as exc:
raise AccountRegisterError("Workspace is not allowed to create.") from exc
except (SeatsLimitExceededError, AccountRegisterError):
raise
except Exception as exc:
raise AccountRegisterError(f"Registration failed: {exc}") from exc
def login(self, account_id: str, *, ip_address: str) -> AccountSessionTokens:
prepared = self._accounts.prepare_session(
account_id,
AccountSessionPreparation(logged_in_at=self._now(), ip_address=ip_address, activate_pending_account=True),
)
if not prepared:
raise AccountNotFoundError()
return self._sessions.issue(account_id)
class InstallationStore(Protocol):
def mark_setup(self) -> None: ...
def reset_setup(self) -> None: ...
class InstallationTelemetry(Protocol):
def report(self) -> None: ...
class AccountSetupProvisioner:
def __init__(
self,
*,
accounts: AccountService,
workspaces: AccountWorkspaceProvisioner,
installation: InstallationStore,
telemetry: InstallationTelemetry,
) -> None:
self._accounts = accounts
self._workspaces = workspaces
self._installation = installation
self._telemetry = telemetry
def provision(self, setup: SetupInput) -> None:
try:
account = self._accounts.create_account(
email=setup.email,
name=setup.name,
interface_language=get_valid_language(setup.language),
password=setup.password,
ip_address=setup.ip_address,
is_setup=True,
initialized=True,
)
self._workspaces.create_owner_workspace(account.id, is_setup=True, if_missing=True)
self._installation.mark_setup()
except Exception as exc:
self._installation.reset_setup()
raise ValueError(f"Setup failed: {exc}") from exc
self._telemetry.report()
+7 -17
View File
@@ -2,6 +2,12 @@
from typing import Protocol
from services.account_errors import (
AccountEmailDomainSuspendedError,
FrozenAccountError,
InvalidInvitationError,
InvitationAccountMismatchError,
)
from services.entities.account_activation_entities import (
AccountInvitation,
AccountSetup,
@@ -52,22 +58,6 @@ class WorkspaceMemberAccessSync(Protocol):
def sync(self, workspace_id: str, account_id: str) -> None: ...
class InvalidInvitationError(Exception):
"""The invitation is invalid, stale, or missing required activation data."""
class InvitationAccountMismatchError(Exception):
"""An authenticated account attempted to consume another account's invitation."""
class FrozenAccountError(Exception):
"""The invited account is temporarily ineligible for activation."""
class EmailDomainSuspendedError(Exception):
"""The invited account uses a suspended email domain."""
class AccountActivationService:
def __init__(
self,
@@ -113,7 +103,7 @@ class AccountActivationService:
freeze_type = self._eligibility.get_freeze_type(invitation.account_email)
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError
raise AccountEmailDomainSuspendedError
if freeze_type:
raise FrozenAccountError
+38 -3
View File
@@ -5,14 +5,46 @@ class AccountApplicationError(Exception):
"""Base class for failures owned by account application services."""
class AccountRegisterError(AccountApplicationError):
"""Account registration was rejected by a lifecycle policy."""
def __init__(self, description: str = "") -> None:
super().__init__(description)
self.description = description
class AccountLoginError(AccountApplicationError):
"""The account cannot sign in."""
class AccountPasswordError(AccountApplicationError):
"""The supplied sign-in password is incorrect."""
class SeatsLimitExceededError(AccountApplicationError):
"""The deployment has no capacity for another account."""
class AccountNotFoundError(AccountApplicationError):
"""The admitted account no longer exists."""
"""The requested account does not exist."""
class AccountSessionNotFoundError(AccountApplicationError):
"""The requested access session is not owned by the admitted account."""
class InvalidInvitationError(AccountApplicationError):
"""The invitation is invalid, stale, or missing required activation data."""
class InvitationAccountMismatchError(AccountApplicationError):
"""An authenticated account attempted to consume another account's invitation."""
class FrozenAccountError(AccountApplicationError):
"""The invited account is temporarily ineligible for activation."""
class CurrentAccountPasswordIncorrectError(AccountApplicationError):
"""The supplied current password does not match the account credential."""
@@ -109,15 +141,18 @@ class InvalidChangeEmailCodeError(AccountApplicationError):
"""The verification code does not match the current token."""
class AccountEmailFrozenError(AccountApplicationError):
class AccountEmailFrozenError(AccountRegisterError):
"""The target email is temporarily frozen by account policy."""
class AccountEmailDomainSuspendedError(AccountEmailFrozenError):
"""The target email belongs to a suspended domain."""
def __init__(self, description: str = "This email domain has been suspended.") -> None:
super().__init__(description)
class AccountEmailAlreadyInUseError(AccountApplicationError):
class AccountEmailAlreadyInUseError(AccountRegisterError):
"""The target email already belongs to an account."""
File diff suppressed because it is too large. Load diff
+13
View File
@@ -14,6 +14,7 @@ from extensions.ext_redis import redis_client
from libs.datetime_utils import naive_utc_now
from libs.login import current_account_with_tenant
from libs.pagination import paginate_query
from models.account import Account
from models.dataset import DatasetCollectionBinding
from models.model import App, AppAnnotationHitHistory, AppAnnotationSetting, Message, MessageAnnotation
from services.app_ref_service import AnnotationRef, AppRef
@@ -597,6 +598,18 @@ class AppAnnotationService:
return None
return annotation
@classmethod
def get_annotation_reply_by_id(
cls, annotation_id: str, session: Session
) -> tuple[MessageAnnotation, str | None] | None:
"""Read a reply and its author together, retaining annotations whose account was deleted."""
row = session.execute(
select(MessageAnnotation, Account.name)
.outerjoin(Account, Account.id == MessageAnnotation.account_id)
.where(MessageAnnotation.id == annotation_id)
).one_or_none()
return (row[0], row[1]) if row is not None else None
@classmethod
def add_annotation_history(
cls,
+1 -1
View File
@@ -61,7 +61,7 @@ from services.entities.dsl_entities import (
Import,
ImportStatus,
)
from services.errors.account import NoPermissionError
from services.errors.base import NoPermissionError
from services.feature_service import FeatureService
from services.recommended_app_package_service import RecommendedAppPackageService
from services.system_feature_service import SystemFeatureService
+1 -1
View File
@@ -71,8 +71,8 @@ from services.entities.dsl_entities import (
make_app_dsl,
)
from services.entities.site_dsl import SiteDsl, apply_site_dsl
from services.errors.account import NoPermissionError
from services.errors.app import WorkflowNotFoundError
from services.errors.base import NoPermissionError
from services.feature_service import FeatureService
from services.icon_configuration import (
DEFAULT_ICON,
-13
View File
@@ -192,19 +192,6 @@ class CreditPoolService:
.with_for_update()
)
@classmethod
def create_default_pool(cls, tenant_id: str, session: Session) -> TenantCreditPool:
"""create default credit pool for new tenant"""
credit_pool = TenantCreditPool(
tenant_id=tenant_id,
quota_limit=dify_config.HOSTED_POOL_CREDITS,
quota_used=0,
pool_type=ProviderQuotaType.TRIAL,
)
session.add(credit_pool)
session.commit()
return credit_pool
@classmethod
def get_pool(
cls,
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from services.entities.account_entities import AccountPasswordDigest
from services.entities.account_entities import AccountPasswordDigest, AccountSessionTokens
class EmailCodeChallengeStatus(StrEnum):
@@ -54,13 +54,6 @@ class AccountSessionPreparation:
activate_pending_account: bool
@dataclass(frozen=True, slots=True)
class AuthTokenPair:
access_token: str
refresh_token: str
csrf_token: str
@dataclass(frozen=True, slots=True)
class PasswordLoginCommand:
email: str
@@ -90,5 +83,5 @@ class EmailCodeLoginCommand:
@dataclass(frozen=True, slots=True)
class PasswordLoginResult:
token_pair: AuthTokenPair | None
tokens: AccountSessionTokens | None
workspace_found: bool
-2
View File
@@ -1,5 +1,4 @@
from . import (
account,
app,
app_model_config,
audio,
@@ -14,7 +13,6 @@ from . import (
)
__all__ = [
"account",
"app",
"app_model_config",
"audio",
-70
View File
@@ -1,70 +0,0 @@
from services.errors.base import BaseServiceError
class AccountNotFoundError(BaseServiceError):
pass
class AccountRegisterError(BaseServiceError):
pass
class AccountEmailAlreadyInUseError(AccountRegisterError):
pass
class AccountNormalizedEmailAlreadyInUseError(AccountEmailAlreadyInUseError):
pass
class EmailDomainSuspendedError(AccountRegisterError):
def __init__(self, description: str = "This email domain has been suspended."):
super().__init__(description)
class AccountLoginError(BaseServiceError):
pass
class AccountPasswordError(BaseServiceError):
pass
class RefreshTokenNotFoundError(BaseServiceError):
pass
class AccountNotLinkTenantError(BaseServiceError):
pass
class TenantNotFoundError(BaseServiceError):
pass
class AccountAlreadyInTenantError(BaseServiceError):
pass
class SeatsLimitExceededError(BaseServiceError):
pass
class InvalidActionError(BaseServiceError):
pass
class CannotOperateSelfError(BaseServiceError):
pass
class NoPermissionError(BaseServiceError):
pass
class MemberNotInTenantError(BaseServiceError):
pass
class RoleAlreadyAssignedError(BaseServiceError):
pass
+4
View File
@@ -1,3 +1,7 @@
class BaseServiceError(ValueError):
def __init__(self, description: str = ""):
self.description = description
class NoPermissionError(Exception):
"""The actor is not permitted to perform the requested operation."""
+89 -3
View File
@@ -1,9 +1,95 @@
from services.errors.base import BaseServiceError
"""Framework-neutral failures owned by workspace application services."""
class WorkSpaceNotAllowedCreateError(BaseServiceError):
class WorkspaceApplicationError(Exception):
"""Base class for workspace use-case failures, mapped by transport adapters."""
class AccountAlreadyInTenantError(WorkspaceApplicationError):
pass
class WorkspacesLimitExceededError(BaseServiceError):
class InvalidActionError(WorkspaceApplicationError):
pass
class CannotOperateSelfError(WorkspaceApplicationError):
pass
class MemberNotInTenantError(WorkspaceApplicationError):
pass
class RoleAlreadyAssignedError(WorkspaceApplicationError):
pass
class WorkSpaceNotAllowedCreateError(WorkspaceApplicationError):
pass
class WorkspacesLimitExceededError(WorkspaceApplicationError):
pass
class WorkspaceNotFoundError(WorkspaceApplicationError):
pass
class WorkspaceArchivedError(WorkspaceApplicationError):
pass
class WorkspaceNotLinkedError(WorkspaceApplicationError):
pass
class WorkspaceOwnerNotFoundError(WorkspaceApplicationError):
pass
class WorkspaceAlreadyHasOwnerError(WorkspaceApplicationError):
"""An owner cannot join a workspace that already has one."""
class InvalidWorkspaceMemberRoleError(WorkspaceApplicationError):
pass
class OwnerTransferSendIPLimitedError(WorkspaceApplicationError):
"""The caller IP exceeded the ownership-transfer email-send policy."""
class OwnerTransferSendRateLimitError(WorkspaceApplicationError):
"""Too many ownership-transfer confirmation messages were requested."""
def __init__(self, retry_after_minutes: int) -> None:
super().__init__("Too many ownership-transfer confirmation messages")
self.retry_after_minutes = retry_after_minutes
class OwnerTransferVerificationLimitError(WorkspaceApplicationError):
"""Too many invalid ownership-transfer verification codes were submitted."""
class InvalidOwnerTransferTokenError(WorkspaceApplicationError):
"""The ownership-transfer token is missing or invalid."""
class InvalidOwnerTransferEmailError(WorkspaceApplicationError):
"""The ownership-transfer token belongs to another email address."""
class InvalidOwnerTransferCodeError(WorkspaceApplicationError):
"""The verification code does not match the ownership-transfer token."""
class WorkspaceInvitationQuotaError(WorkspaceApplicationError):
def __init__(self, *, seats: bool = False) -> None:
super().__init__("Workspace invitation quota exceeded")
self.seats = seats
class WorkspaceMemberLicenseQuotaError(WorkspaceInvitationQuotaError):
"""The workspace member allowance in the license is exhausted."""
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import Protocol
from machinery.context import RequestContext
from services.auth.api_key_contracts import ApiKeyCache, ApiKeyRecord, ApiKeyResourceNotFoundError
from services.errors.account import NoPermissionError
from services.errors.base import NoPermissionError
from services.knowledge.dataset_access import (
DatasetAccess,
DatasetAccessDeniedError,
+1 -1
View File
@@ -73,7 +73,7 @@ from services.entities.knowledge_entities.rag_pipeline_entities import (
KnowledgeConfiguration,
RagPipelineDatasetCreateEntity,
)
from services.errors.account import NoPermissionError
from services.errors.base import NoPermissionError
from services.errors.chunk import ChildChunkDeleteIndexError, ChildChunkIndexingError
from services.errors.dataset import DatasetNameDuplicateError
from services.errors.document import DocumentIndexingError
+1 -1
View File
@@ -18,7 +18,7 @@ from models.provider_ids import ModelProviderID
from repositories.knowledge.dataset_repository import _get_dataset
from services.app.query_service import AppQueryService
from services.enterprise import rbac_service
from services.errors.account import NoPermissionError
from services.errors.base import NoPermissionError
from services.knowledge.dataset_access import DatasetAccessDeniedError, DatasetNotFoundError
from services.knowledge.dataset_read_service import get_dataset_queries, load_dataset_detail, load_dataset_details
from services.knowledge.dataset_service import DatasetPermissionService, DatasetService, DocumentService
+1 -1
View File
@@ -35,7 +35,7 @@ from repositories.knowledge.dataset_read_repository import (
from repositories.knowledge.dataset_repository import _get_dataset
from repositories.knowledge.document_repository import _get_document
from services.enterprise import rbac_service
from services.errors.account import NoPermissionError
from services.errors.base import NoPermissionError
from services.errors.document import DocumentIndexingError
from services.file_service import FileService
from services.knowledge.dataset_access import DatasetAccessDeniedError, DatasetNotFoundError
@@ -17,8 +17,8 @@ from core.model_manager import ModelInstance, ModelManager
from core.plugin.impl.exc import PluginDaemonClientSideError
from core.rag.extractor.entity.extract_setting import ExtractSetting
from core.rag.index_processor.constant.index_type import IndexTechniqueType
from core.rag.index_processor.index_processor import IndexProcessorFactory
from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict
from core.rag.index_processor.index_processor_factory import IndexProcessorFactory
from core.tools.utils.web_reader_tool import get_image_upload_file_ids
from enums import DeploymentEdition
from extensions.ext_storage import storage
@@ -22,7 +22,7 @@ from core.rag.datasource.keyword.jieba.jieba import Jieba
from core.rag.datasource.vdb.vector_factory import Vector
from core.rag.embedding.token_counter import calculate_segment_token_counts
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
from core.rag.index_processor.index_processor_factory import IndexProcessorFactory
from core.rag.index_processor.index_processor import IndexProcessorFactory
from core.rag.models.document import Document
from extensions.application_services.data_sources import build_data_source_credentials
from extensions.ext_redis import redis_client
+1 -21
View File
@@ -3,33 +3,13 @@
from contextlib import AbstractContextManager
from typing import override
from sqlalchemy.orm import Session, sessionmaker
from extensions.ext_redis import RedisClientWrapper
from services.account_service import RegisterService
from services.setup_service import SetupAccountProvisioner, SetupInput, SetupLock
from services.setup_service import SetupLock
_SETUP_LOCK_KEY = "setup:initialize"
_SETUP_LOCK_TIMEOUT_SECONDS = 300
class RegisterServiceAccountProvisioner(SetupAccountProvisioner):
def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def provision(self, setup: SetupInput) -> None:
with self._session_factory() as session:
RegisterService.setup(
email=setup.email,
name=setup.name,
password=setup.password,
ip_address=setup.ip_address,
language=setup.language,
session=session,
)
class RedisSetupLock(SetupLock):
def __init__(self, *, client: RedisClientWrapper) -> None:
self._client = client
+1 -15
View File
@@ -1,24 +1,10 @@
"""Application service for first-time Dify setup."""
from contextlib import AbstractContextManager
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
@dataclass(frozen=True, slots=True)
class SetupInput:
email: str
name: str
password: str
ip_address: str
language: str | None
@dataclass(frozen=True, slots=True)
class SetupStatus:
completed: bool
setup_at: datetime | None = None
from services.account.contracts import SetupInput, SetupStatus
class SetupState(Protocol):
+1 -1
View File
@@ -18,7 +18,7 @@ from libs.schedule_utils import calculate_next_run_at, convert_12h_to_24h
from models.account import Account, TenantAccountJoin
from models.trigger import WorkflowSchedulePlan
from models.workflow import Workflow
from services.errors.account import AccountNotFoundError
from services.account_errors import AccountNotFoundError
logger = logging.getLogger(__name__)
+16 -9
View File
@@ -7,7 +7,8 @@ from typing import Any, override
from libs.helper import RateLimiter, TokenManager
from libs.passport import PassportService
from services.account_service import AccountService
from services.account.forgot_password_service import ForgotPasswordSecurityGateway
from services.account.login_service import ConsoleAuthSecurityGateway
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.auth_audit_entities import LoginFailureReason
from services.entities.authentication_entities import StoredAuthenticationToken
@@ -136,29 +137,35 @@ class TokenManagerWebAuthenticationGateway(WebAuthenticationTokenGateway):
)
class AccountServiceWebAuthenticationSecurityGateway(WebAuthenticationSecurityGateway):
def __init__(self, *, account_service: type[AccountService]) -> None:
self._account_service = account_service
class AccountWebAuthenticationSecurityGateway(WebAuthenticationSecurityGateway):
def __init__(
self,
*,
password_security: ForgotPasswordSecurityGateway,
login_security: ConsoleAuthSecurityGateway,
) -> None:
self._password_security = password_security
self._login_security = login_security
@override
def is_email_send_ip_limited(self, ip_address: str) -> bool:
return bool(self._account_service.is_email_send_ip_limit(ip_address))
return self._password_security.is_ip_limited(ip_address)
@override
def is_password_reset_verification_limited(self, email: str) -> bool:
return bool(self._account_service.is_forgot_password_error_rate_limit(email))
return self._password_security.is_verification_limited(email)
@override
def record_password_reset_verification_failure(self, email: str) -> None:
self._account_service.add_forgot_password_error_rate_limit(email)
self._password_security.record_verification_failure(email)
@override
def reset_password_reset_verification_failures(self, email: str) -> None:
self._account_service.reset_forgot_password_error_rate_limit(email)
self._password_security.reset_verification_failures(email)
@override
def reset_login_failures(self, email: str) -> None:
self._account_service.reset_login_error_rate_limit(email)
self._login_security.reset_login_failures(email)
class PassportWebAppSessionGateway(WebAppSessionGateway):
+1
View File
@@ -0,0 +1 @@
"""Workspace domain modules."""
+213
View File
@@ -0,0 +1,213 @@
"""Shared workspace data, independent of service implementations."""
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, NamedTuple
from enums import CloudPlan
@dataclass(frozen=True)
class WorkspaceCustomConfig:
remove_webapp_brand: bool = False
replace_webapp_logo: str | None = None
@dataclass(frozen=True)
class WorkspaceCustomConfigChanges:
remove_webapp_brand: bool | None = None
replace_webapp_logo: str | None = None
@dataclass(frozen=True)
class WorkspaceSnapshot:
id: str
name: str
status: str
created_at: datetime
role: str | None = None
custom_config: WorkspaceCustomConfig = field(default_factory=WorkspaceCustomConfig)
has_privileged_member: bool = False
current: bool = False
@dataclass(frozen=True, slots=True)
class WorkspaceMemberWrite:
workspace_id: str
account_id: str
role: str
account_status: str
created: bool
@dataclass(frozen=True, slots=True)
class WorkspaceMemberRemoval:
account_id: str
email: str
account_deleted: bool
@dataclass(frozen=True)
class WorkspacePage:
data: tuple[WorkspaceSnapshot, ...]
total: int
page: int
limit: int
has_more: bool
@dataclass(frozen=True)
class WorkspaceFeatures:
can_replace_logo: bool
credits: "EffectiveCreditPool"
@dataclass(frozen=True)
class WorkspacePermission:
workspace_id: str
allow_member_invite: bool
allow_owner_transfer: bool
@dataclass(frozen=True)
class CreatedWorkspace:
id: str
name: str
plan: str
status: str
created_at: datetime | None
updated_at: datetime | None
encrypt_public_key: str | None
custom_config: dict[str, object]
@dataclass(frozen=True)
class WorkspaceMembership:
workspace_id: str
account_id: str
role: str
@dataclass(frozen=True, slots=True)
class OwnerTransferToken:
email: str
code: str
account_id: str | None = None
@dataclass(frozen=True, slots=True)
class WorkspaceInvitationResult:
email: str
status: Literal["success", "already_member", "failed"]
token: str | None = None
message: str | None = None
@dataclass(frozen=True, slots=True)
class WorkspaceInvitation:
workspace_id: str
account_id: str
email: str
role: str
token: str
@dataclass(frozen=True)
class EffectiveCreditPool:
plan: CloudPlan | None = None
pool_type: Literal["paid", "trial"] | None = None
quota_limit: int | None = None
quota_used: int | None = None
exhausted_at: int | None = None
next_credit_reset_date: int | None = None
@property
def remaining_credits(self) -> int | None:
if self.quota_limit is None or self.quota_used is None:
return None
if self.is_unlimited:
return -1
return max(0, self.quota_limit - self.quota_used)
@property
def is_unlimited(self) -> bool:
return self.quota_limit == -1
@property
def is_exhausted(self) -> bool:
remaining_credits = self.remaining_credits
return not self.is_unlimited and (remaining_credits is None or remaining_credits <= 0)
class WorkspaceRecord(NamedTuple):
id: str
name: str | None
status: str
created_at: datetime
last_opened_at: datetime | None
class WorkspaceSummary(NamedTuple):
id: str
name: str | None
plan: str
status: str
created_at: datetime
last_opened_at: datetime | None
current: bool
class WorkspaceMemberRole(NamedTuple):
id: str
name: str
class WorkspaceMemberRecord(NamedTuple):
id: str
name: str
email: str
avatar: str | None
last_login_at: datetime | None
last_active_at: datetime
created_at: datetime
status: str
legacy_role: str
@property
def role(self) -> str:
return self.legacy_role
@dataclass(frozen=True, slots=True)
class WorkspaceMemberPage:
members: tuple[WorkspaceMemberRecord, ...]
total: int
class WorkspaceMemberRoleSubject(NamedTuple):
account_id: str
legacy_role: str
class WorkspaceMemberSummary(NamedTuple):
id: str
name: str
email: str
avatar: str | None
last_login_at: datetime | None
last_active_at: datetime
created_at: datetime
role: str
roles: tuple[WorkspaceMemberRole, ...]
status: str
@dataclass(frozen=True, slots=True)
class WorkspaceCreation:
id: str
name: str
encrypt_public_key: str
trial_credits: int
plugin_upgrade_time: int
plugin_upgrade_settings: Mapping[str, str]
+511
View File
@@ -0,0 +1,511 @@
"""Adapters for workspace plans, features, files, member roles, and provisioning."""
import logging
import secrets
from collections.abc import Mapping, Sequence
from contextlib import AbstractContextManager
from typing import Literal, override
from uuid import uuid4
from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import Forbidden
from configs import dify_config
from enums import CloudPlan, DeploymentEdition
from events.tenant_event import tenant_was_created
from extensions.ext_redis import RedisClientWrapper
from libs.helper import RateLimiter, TokenManager
from libs.key_providers import generate_key_pair
from libs.workspace_permission import check_workspace_member_invite_permission
from machinery.context import RequestContext
from models.account import (
Account,
AccountStatus,
Tenant,
TenantAccountRole,
TenantPluginAutoUpgradeCategory,
)
from repositories.workspace.workspace_repository import WorkspaceRepository
from services.account.adapters import RedisInvitationTokenStore
from services.account_security_gateway import RedisAccountEmailSecurityGateway
from services.billing_service import BillingService
from services.credit_pool_service import CreditPoolBalance, CreditPoolService
from services.enterprise import rbac_service as enterprise_rbac_service
from services.enterprise.enterprise_service import EnterpriseService
from services.enterprise.rbac_service import ListOption, RBACService
from services.entities.account_activation_entities import InvitationToken
from services.entities.account_entities import AccountSnapshot
from services.errors.base import NoPermissionError
from services.errors.workspace import (
InvalidWorkspaceMemberRoleError,
OwnerTransferSendRateLimitError,
WorkspaceInvitationQuotaError,
WorkspaceMemberLicenseQuotaError,
)
from services.feature_service import FeatureService
from services.file_service import FileService
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.system_feature_service import SystemFeatureService
from services.workspace.contracts import (
CreatedWorkspace,
EffectiveCreditPool,
OwnerTransferToken,
WorkspaceCreation,
WorkspaceFeatures,
WorkspaceMemberRemoval,
WorkspaceMemberRole,
WorkspaceMemberRoleSubject,
WorkspaceMemberWrite,
WorkspacePermission,
)
from services.workspace.member_service import WorkspaceMemberRoleResolver
from services.workspace.service import WorkspaceFeatureGateway, WorkspaceLogoGateway, WorkspacePlanGateway
from tasks.mail_invite_member_task import send_invite_member_mail_task
from tasks.mail_owner_transfer_task import (
send_new_owner_transfer_notify_email_task,
send_old_owner_transfer_notify_email_task,
send_owner_transfer_confirm_task,
)
logger = logging.getLogger(__name__)
class WorkspaceIdentityGateway:
"""Materialize workspace identities for adapters that require the ORM model."""
def __init__(self, *, workspaces: WorkspaceRepository) -> None:
self._workspaces = workspaces
def get_workspace(self, workspace_id: str) -> Tenant | None:
return self._workspaces.get_model(workspace_id)
class WorkspaceOwnerTransferGateway(RedisAccountEmailSecurityGateway):
def __init__(self, *, redis: RedisClientWrapper) -> None:
super().__init__(
redis=redis,
email_send_ip_limit_per_minute=dify_config.EMAIL_SEND_IP_LIMIT_PER_MINUTE,
verification_failure_limit=5,
verification_lockout_duration=dify_config.OWNER_TRANSFER_LOCKOUT_DURATION,
verification_key_prefix="owner_transfer_error_rate_limit",
)
self._send_limits = RateLimiter(
prefix="owner_transfer_rate_limit", max_attempts=1, time_window=60, redis_client=redis
)
def send_confirmation(self, account: AccountSnapshot, workspace_name: str, language: str) -> str:
if self._send_limits.is_rate_limited(account.email):
raise OwnerTransferSendRateLimitError(int(self._send_limits.time_window / 60))
code = "".join(str(secrets.randbelow(10)) for _ in range(6))
token = self.issue_token(OwnerTransferToken(account.email, code, account.id))
send_owner_transfer_confirm_task.delay(language=language, to=account.email, code=code, workspace=workspace_name)
self._send_limits.increment_rate_limit(account.email)
return token
def read_token(self, token: str) -> OwnerTransferToken | None:
data = TokenManager.get_token_data(token, "owner_transfer")
if data is None or not isinstance(data.get("email"), str) or not isinstance(data.get("code"), str):
return None
return OwnerTransferToken(data["email"], data["code"], data.get("account_id"))
def issue_token(self, token: OwnerTransferToken) -> str:
return TokenManager.generate_token(
account_id=token.account_id,
email=token.email,
token_type="owner_transfer",
additional_data={"code": token.code},
)
def revoke_token(self, token: str) -> None:
TokenManager.revoke_token(token, "owner_transfer")
def notify(self, *, old_owner: AccountSnapshot, new_owner: AccountSnapshot, workspace_name: str) -> None:
send_new_owner_transfer_notify_email_task.delay(language="en-US", to=new_owner.email, workspace=workspace_name)
send_old_owner_transfer_notify_email_task.delay(
language="en-US", to=old_owner.email, workspace=workspace_name, new_owner_email=new_owner.email
)
class DeploymentWorkspaceCreationPolicy:
def is_workspace_creation_allowed(self) -> bool:
return SystemFeatureService.is_workspace_creation_allowed()
def has_workspace_capacity(self) -> bool:
return SystemFeatureService.get_license().workspaces.is_available()
def _ensure_role_enabled(role: str) -> None:
if role == "dataset_operator" and not dify_config.DATASET_OPERATOR_ENABLED:
raise InvalidWorkspaceMemberRoleError()
class WorkspaceInvitationGateway:
def __init__(self, *, tokens: RedisInvitationTokenStore, redis: RedisClientWrapper) -> None:
self._tokens = tokens
self._redis = redis
@property
def requires_capacity_check(self) -> bool:
return dify_config.DEPLOYMENT_EDITION in {DeploymentEdition.CLOUD, DeploymentEdition.ENTERPRISE}
def acquire(self, workspace_id: str) -> AbstractContextManager[object]:
return self._redis.lock(f"workspace_member_invite:{workspace_id}", timeout=60)
def ensure_role_enabled(self, role: str) -> None:
_ensure_role_enabled(role)
def check_invitation_quota(self, workspace_id: str) -> None:
"""Admit a single invitation using the advertised plan and license limits.
Unlike batch capacity checks, this gate also applies to reinvitations
and uses the billing-reported member count.
"""
features = FeatureService.get_features(tenant_id=workspace_id, exclude_vector_space=True)
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
if 0 < features.members.limit <= features.members.size:
raise WorkspaceInvitationQuotaError()
if features.workspace_members.enabled and not features.workspace_members.is_available(1):
raise WorkspaceMemberLicenseQuotaError()
def check_capacity(self, workspace_id: str, *, current_members: int, new_members: int, new_accounts: int) -> None:
if dify_config.DEPLOYMENT_EDITION not in {DeploymentEdition.CLOUD, DeploymentEdition.ENTERPRISE}:
return
features = FeatureService.get_features(tenant_id=workspace_id, exclude_vector_space=True)
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
quota = features.workspace_members
if quota.enabled and not quota.is_available(new_members):
raise WorkspaceMemberLicenseQuotaError()
if new_accounts and not SystemFeatureService.get_license().seats.is_available(new_accounts):
raise WorkspaceInvitationQuotaError(seats=True)
elif 0 < features.members.limit < current_members + new_members:
raise WorkspaceInvitationQuotaError()
def ensure_allowed(self, workspace_id: str) -> None:
try:
check_workspace_member_invite_permission(workspace_id)
except Forbidden as error:
raise NoPermissionError(error.description) from error
def create(self, invitation: InvitationToken) -> str:
return self._tokens.create(invitation)
def send(self, *, language: str, email: str, token: str, inviter_name: str, workspace_name: str) -> None:
send_invite_member_mail_task.delay(
language=language,
to=email,
token=token,
inviter_name=inviter_name,
workspace_name=workspace_name,
)
class DeploymentWorkspaceMemberAccessGateway:
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
def ensure_role_enabled(self, role: str) -> None:
_ensure_role_enabled(role)
@property
def rbac_enabled(self) -> bool:
return dify_config.RBAC_ENABLED
def permission_keys(self, workspace_id: str, actor_id: str) -> set[str]:
with self._session_factory() as session:
permissions = RBACService.MyPermissions.get(workspace_id, actor_id, session=session)
return set(permissions.workspace.permission_keys)
def is_owner(self, workspace_id: str, actor_id: str, member_id: str) -> bool:
with self._session_factory() as session:
roles = RBACService.MemberRoles.get(workspace_id, actor_id, member_id, session=session).roles
return any(
role.is_builtin and role.category == "global_system_default" and role.role_tag == "owner"
for role in roles
)
@staticmethod
def _role_id(workspace_id: str, actor_id: str, tag: str) -> str:
roles = RBACService.Roles.list(
workspace_id, actor_id, options=ListOption(page_number=1, results_per_page=100)
).data
for role in roles:
if role.is_builtin and role.category == "global_system_default" and role.role_tag == tag:
return str(role.id)
raise ValueError(f"Builtin RBAC role not found for tag {tag!r} in tenant {workspace_id}")
def owner_id(self, workspace_id: str, actor_id: str) -> str:
members = RBACService.Roles.members(
tenant_id=workspace_id,
account_id=actor_id,
role_id=self._role_id(workspace_id, actor_id, "owner"),
options=ListOption(page_number=1, results_per_page=1),
).data
if not members:
raise ValueError(f"Workspace RBAC owner not found for tenant {workspace_id}.")
return members[0].account_id
def assign_role(self, workspace_id: str, actor_id: str, member_id: str, role_id: str) -> None:
with self._session_factory() as session:
RBACService.MemberRoles.replace(
tenant_id=workspace_id,
account_id=actor_id,
member_account_id=member_id,
role_ids=[role_id],
session=session,
)
session.commit()
def change_role(self, workspace_id: str, actor_id: str, member_id: str, role: TenantAccountRole) -> None:
if not TenantAccountRole.is_non_owner_role(role):
raise InvalidWorkspaceMemberRoleError("Owner changes require ownership transfer.")
self.assign_role(workspace_id, actor_id, member_id, self._role_id(workspace_id, actor_id, role.value))
def transfer_owner(self, workspace_id: str, actor_id: str, member_id: str) -> None:
owner_role_id = self._role_id(workspace_id, actor_id, "owner")
old_owner_id = self.owner_id(workspace_id, actor_id)
no_access_id = self._role_id(workspace_id, actor_id, "no_access")
# With RBAC enabled these calls update the enterprise service, not the local
# Session. Complete both before the repository commits local owner changes.
with self._session_factory() as session:
current = RBACService.MemberRoles.get(workspace_id, actor_id, old_owner_id, session=session).roles
remaining = [str(item.id) for item in current if str(item.id) != owner_role_id]
RBACService.MemberRoles.replace(
tenant_id=workspace_id,
account_id=actor_id,
member_account_id=old_owner_id,
role_ids=remaining or [no_access_id],
session=session,
)
RBACService.MemberRoles.replace(
tenant_id=workspace_id,
account_id=actor_id,
member_account_id=member_id,
role_ids=[owner_role_id],
session=session,
)
def membership_changed(self, membership: WorkspaceMemberWrite, operator_account_id: str | None) -> None:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
BillingService.clean_billing_info_cache(membership.workspace_id)
if (
membership.created
and self.rbac_enabled
and membership.role != TenantAccountRole.OWNER
and membership.account_status != AccountStatus.PENDING
):
from tasks.initialize_created_app_rbac_access_task import sync_joined_workspace_member_rbac_access_task
sync_joined_workspace_member_rbac_access_task.delay(
membership.workspace_id,
membership.account_id,
operator_account_id=operator_account_id,
)
def member_removed(self, workspace_id: str, removal: WorkspaceMemberRemoval) -> None:
if removal.account_deleted:
logger.info("Deleted orphaned pending account: account_id=%s, email=%s", removal.account_id, removal.email)
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
BillingService.clean_billing_info_cache(workspace_id)
from services.enterprise.account_deletion_sync import sync_workspace_member_removal
if not sync_workspace_member_removal(
workspace_id=workspace_id, member_id=removal.account_id, source="workspace_member_removed"
):
logger.warning(
"Enterprise workspace member removal sync failed: workspace_id=%s, member_id=%s",
workspace_id,
removal.account_id,
)
if self.rbac_enabled:
RBACService.MemberRoles.delete_rbac_bindings(tenant_id=workspace_id, account_id=removal.account_id)
class DeploymentWorkspacePlanGateway(WorkspacePlanGateway):
"""Resolve workspace plans using deployment-specific Billing and Feature sources."""
@override
def resolve_many(self, workspace_ids: Sequence[str]) -> Mapping[str, str]:
ids = tuple(workspace_ids)
if not ids:
return {}
is_enterprise_only = dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE
if is_enterprise_only:
return dict.fromkeys(ids, str(CloudPlan.SANDBOX))
is_saas = dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD
bulk_plans = BillingService.get_plan_bulk(ids) if is_saas else {}
if is_saas and not bulk_plans:
logger.warning("get_plan_bulk returned empty result, falling back to FeatureService")
resolved: dict[str, str] = {}
for workspace_id in ids:
tenant_plan = bulk_plans.get(workspace_id)
if tenant_plan:
resolved[workspace_id] = tenant_plan["plan"] or CloudPlan.SANDBOX
continue
features = FeatureService.get_features(workspace_id, exclude_vector_space=True)
resolved[workspace_id] = features.billing.subscription.plan or CloudPlan.SANDBOX
return resolved
class DeploymentWorkspaceFeatureGateway(WorkspaceFeatureGateway):
@override
def get_features(self, workspace_id: str) -> WorkspaceFeatures:
features = FeatureService.get_features(workspace_id, exclude_vector_space=True)
credits = EffectiveCreditPool()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
credits = self._credit_pool(
workspace_id, CloudPlan(features.billing.subscription.plan), features.next_credit_reset_date
)
return WorkspaceFeatures(can_replace_logo=features.can_replace_logo, credits=credits)
@override
def get_effective_credit_pool(self, workspace_id: str) -> EffectiveCreditPool:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return EffectiveCreditPool()
billing = BillingService.get_info(workspace_id, exclude_vector_space=True)
return self._credit_pool(
workspace_id, CloudPlan(billing["subscription"]["plan"]), billing.get("next_credit_reset_date")
)
@staticmethod
def _credit_pool(workspace_id: str, plan: CloudPlan, reset_date: int | None) -> EffectiveCreditPool:
# In Cloud, CreditPoolService reads the billing quota API, without an ORM Session.
pool = None
pool_type: Literal["paid", "trial"] = "trial"
if plan != CloudPlan.SANDBOX:
paid = CreditPoolService.get_pool(tenant_id=workspace_id, pool_type="paid")
if paid is not None and (paid.quota_limit == -1 or paid.quota_limit > paid.quota_used):
pool, pool_type = paid, "paid"
if pool is None:
pool = CreditPoolService.get_pool(tenant_id=workspace_id, pool_type="trial")
if pool is None:
return EffectiveCreditPool(plan=plan, next_credit_reset_date=reset_date)
exhausted_at = pool.exhausted_at if isinstance(pool, CreditPoolBalance) else None
if not (
isinstance(exhausted_at, int)
and exhausted_at > 0
and pool.quota_limit > 0
and pool.quota_used >= pool.quota_limit
):
exhausted_at = None
return EffectiveCreditPool(
plan=plan,
pool_type=pool_type,
quota_limit=pool.quota_limit,
quota_used=pool.quota_used,
exhausted_at=exhausted_at,
next_credit_reset_date=reset_date,
)
@override
def logo_url(self, workspace_id: str) -> str:
return f"{dify_config.FILES_URL}/files/workspaces/{workspace_id}/webapp-logo"
@override
def get_permission(self, workspace_id: str) -> WorkspacePermission:
permission = EnterpriseService.WorkspacePermissionService.get_permission(workspace_id)
return WorkspacePermission(
workspace_id=permission.workspace_id,
allow_member_invite=permission.allow_member_invite,
allow_owner_transfer=permission.allow_owner_transfer,
)
class WorkspaceFileGateway(WorkspaceLogoGateway):
def __init__(self, *, files: FileService) -> None:
self._files = files
@override
def upload(self, context: RequestContext, *, filename: str, content: bytes, mimetype: str) -> str:
# FileService only needs the admitted account ID and its explicit tenant ID.
owner = Account(name="", email="")
owner.id = context.account_id
return self._files.upload_file(
filename=filename,
content=content,
mimetype=mimetype,
user=owner,
tenant_id=context.active_workspace_id,
).id
class DeploymentWorkspaceMemberRoleResolver(WorkspaceMemberRoleResolver):
"""Preserve deployment-specific legacy and enterprise role behavior."""
@override
def resolve_many(
self,
workspace_id: str,
actor_account_id: str,
subjects: Sequence[WorkspaceMemberRoleSubject],
) -> Mapping[str, Sequence[WorkspaceMemberRole]]:
role_subjects = tuple(subjects)
if not role_subjects:
return {}
if not dify_config.RBAC_ENABLED:
return {
subject.account_id: (WorkspaceMemberRole(id=subject.legacy_role, name=subject.legacy_role),)
for subject in role_subjects
}
member_roles = enterprise_rbac_service.RBACService.MemberRoles.batch_get(
workspace_id,
actor_account_id,
[subject.account_id for subject in role_subjects],
)
return {
item.account_id: tuple(WorkspaceMemberRole(id=role.id, name=role.name) for role in item.roles)
for item in member_roles
}
class WorkspaceProvisioningEffectsGateway:
"""Prepare external resources and publish committed workspace creation."""
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
def prepare(self, name: str) -> WorkspaceCreation:
workspace_id = str(uuid4())
return WorkspaceCreation(
id=workspace_id,
name=name,
encrypt_public_key=generate_key_pair(workspace_id),
trial_credits=dify_config.HOSTED_POOL_CREDITS,
plugin_upgrade_time=PluginAutoUpgradeService.default_upgrade_time_of_day(workspace_id),
plugin_upgrade_settings={
category.value: PluginAutoUpgradeService.default_strategy_setting_for_category(category).value
for category in TenantPluginAutoUpgradeCategory
},
)
def bind_owner(self, workspace_id: str, account_id: str) -> None:
if not dify_config.RBAC_ENABLED:
return
role_id = DeploymentWorkspaceMemberAccessGateway._role_id(workspace_id, account_id, "owner")
# RBAC uses the enterprise API here. Its shared signature requires a
# Session, but this call does not perform local database queries.
with self._session_factory() as session:
RBACService.MemberRoles.replace(
tenant_id=workspace_id,
account_id=account_id,
member_account_id=account_id,
role_ids=[role_id],
session=session,
)
def created(self, workspace: CreatedWorkspace, *, owner_id: str | None) -> None:
if owner_id is not None and dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
BillingService.clean_billing_info_cache(workspace.id)
tenant_was_created.send(workspace)
def join_default_workspace(self, account_id: str) -> None:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
from services.enterprise.enterprise_service import try_join_default_workspace
try_join_default_workspace(account_id)
+532
View File
@@ -0,0 +1,532 @@
"""Workspace membership, invitation and ownership use cases with application-owned ports."""
from collections.abc import Mapping, Sequence
from contextlib import AbstractContextManager
from typing import Protocol
from enums.account import TenantAccountRole
from machinery.context import RequestContext
from services.account_errors import AccountNotFoundError
from services.account_ports import AccountSnapshotQuery
from services.entities.account_activation_entities import InvitationToken
from services.entities.account_entities import AccountSnapshot
from services.errors.base import NoPermissionError
from services.errors.workspace import (
AccountAlreadyInTenantError,
CannotOperateSelfError,
InvalidActionError,
InvalidOwnerTransferCodeError,
InvalidOwnerTransferEmailError,
InvalidOwnerTransferTokenError,
InvalidWorkspaceMemberRoleError,
MemberNotInTenantError,
OwnerTransferSendIPLimitedError,
OwnerTransferVerificationLimitError,
RoleAlreadyAssignedError,
WorkspaceAlreadyHasOwnerError,
WorkspaceNotFoundError,
)
from services.workspace.contracts import (
OwnerTransferToken,
WorkspaceInvitation,
WorkspaceInvitationResult,
WorkspaceMemberPage,
WorkspaceMemberRecord,
WorkspaceMemberRemoval,
WorkspaceMemberRole,
WorkspaceMemberRoleSubject,
WorkspaceMembership,
WorkspaceMemberSummary,
WorkspaceMemberWrite,
WorkspaceSnapshot,
)
class WorkspaceMemberQuery(Protocol):
def list_for_workspace(
self, workspace_id: str, *, role: TenantAccountRole | None = None
) -> Sequence[WorkspaceMemberRecord]: ...
class WorkspaceMemberRoleResolver(Protocol):
def resolve_many(
self,
workspace_id: str,
actor_account_id: str,
subjects: Sequence[WorkspaceMemberRoleSubject],
) -> Mapping[str, Sequence[WorkspaceMemberRole]]: ...
class WorkspaceMemberQueryService:
def __init__(
self,
*,
members: WorkspaceMemberQuery,
roles: WorkspaceMemberRoleResolver,
) -> None:
self._members = members
self._roles = roles
def list_members(
self, workspace_id: str, *, dataset_operators_only: bool = False
) -> tuple[WorkspaceMemberRecord, ...]:
role = TenantAccountRole.DATASET_OPERATOR if dataset_operators_only else None
return tuple(self._members.list_for_workspace(workspace_id, role=role))
def list_page(self, context: RequestContext, *, page: int, limit: int) -> WorkspaceMemberPage:
records = self.list_members(context.active_workspace_id)
start = (page - 1) * limit
return WorkspaceMemberPage(members=records[start : start + limit], total=len(records))
def list_current(self, context: RequestContext) -> tuple[WorkspaceMemberSummary, ...]:
workspace_id = context.active_workspace_id
records = tuple(self._members.list_for_workspace(workspace_id))
role_subjects = tuple(
WorkspaceMemberRoleSubject(account_id=record.id, legacy_role=record.legacy_role) for record in records
)
# The repository closes its read Session before role resolution
# performs enterprise I/O.
roles_by_member = self._roles.resolve_many(workspace_id, context.account_id, role_subjects)
return tuple(
WorkspaceMemberSummary(
id=record.id,
name=record.name,
email=record.email,
avatar=record.avatar,
last_login_at=record.last_login_at,
last_active_at=record.last_active_at,
created_at=record.created_at,
role=record.legacy_role,
roles=tuple(roles_by_member.get(record.id, ())),
status=record.status,
)
for record in records
)
class WorkspaceMemberStore(Protocol):
def get(self, workspace_id: str) -> WorkspaceSnapshot | None: ...
def member_role(self, workspace_id: str, account_id: str | None) -> TenantAccountRole | None: ...
def owner_id(self, workspace_id: str) -> str | None: ...
def upsert_member(self, *, workspace_id: str, account_id: str, role: TenantAccountRole) -> WorkspaceMemberWrite: ...
def remove_member(self, *, workspace_id: str, account_id: str, owner_id: str) -> WorkspaceMemberRemoval: ...
def update_member_role(self, *, workspace_id: str, account_id: str, role: TenantAccountRole) -> None: ...
class WorkspaceMemberAccessGateway(Protocol):
@property
def rbac_enabled(self) -> bool: ...
def ensure_role_enabled(self, role: str) -> None: ...
def permission_keys(self, workspace_id: str, actor_id: str) -> set[str]: ...
def is_owner(self, workspace_id: str, actor_id: str, member_id: str) -> bool: ...
def owner_id(self, workspace_id: str, actor_id: str) -> str: ...
def assign_role(self, workspace_id: str, actor_id: str, member_id: str, role_id: str) -> None: ...
def change_role(self, workspace_id: str, actor_id: str, member_id: str, role: TenantAccountRole) -> None: ...
def transfer_owner(self, workspace_id: str, actor_id: str, member_id: str) -> None: ...
def membership_changed(self, membership: WorkspaceMemberWrite, operator_account_id: str | None) -> None: ...
def member_removed(self, workspace_id: str, removal: WorkspaceMemberRemoval) -> None: ...
class WorkspaceMemberService:
def __init__(
self, *, workspaces: WorkspaceMemberStore, accounts: AccountSnapshotQuery, access: WorkspaceMemberAccessGateway
) -> None:
self._workspaces = workspaces
self._accounts = accounts
self._access = access
@property
def rbac_enabled(self) -> bool:
return self._access.rbac_enabled
def get_role(self, workspace_id: str, account_id: str | None) -> TenantAccountRole | None:
return self._workspaces.member_role(workspace_id, account_id)
def check_permission(self, workspace_id: str, operator_id: str, member_id: str | None, action: str) -> None:
if action not in {"add", "remove", "update"}:
raise InvalidActionError("Invalid action.")
if member_id == operator_id:
raise CannotOperateSelfError("Cannot operate self.")
if self._access.rbac_enabled:
permission = "workspace.member.manage" if action in {"add", "remove"} else "workspace.role.manage"
if permission not in self._access.permission_keys(workspace_id, operator_id):
raise NoPermissionError(f"No permission to {action} member.")
if action == "remove" and member_id and self._access.is_owner(workspace_id, operator_id, member_id):
raise NoPermissionError(f"No permission to {action} member.")
return
role = self.get_role(workspace_id, operator_id)
if role not in {TenantAccountRole.OWNER, TenantAccountRole.ADMIN}:
raise NoPermissionError(f"No permission to {action} member.")
if (
action == "remove"
and role == TenantAccountRole.ADMIN
and member_id
and self.get_role(workspace_id, member_id) == TenantAccountRole.OWNER
):
raise NoPermissionError(f"No permission to {action} member.")
def join_member(
self,
*,
workspace_id: str,
account_id: str,
email: str,
role: TenantAccountRole,
operator_account_id: str | None,
) -> WorkspaceMembership:
"""Add or update a member and synchronize their workspace access.
Raises:
WorkspaceNotFoundError: The workspace is missing or is not active.
AccountNotFoundError: The account is missing or its email does not match.
WorkspaceAlreadyHasOwnerError: An owner is requested but the workspace
already has one.
"""
workspace = self._workspaces.get(workspace_id)
if workspace is None or workspace.status != "normal":
raise WorkspaceNotFoundError()
account = self._accounts.get(account_id)
if account is None or account.email != email:
raise AccountNotFoundError()
if role == TenantAccountRole.OWNER and self._workspaces.owner_id(workspace_id) is not None:
raise WorkspaceAlreadyHasOwnerError("Tenant already has an owner.")
membership = self._workspaces.upsert_member(workspace_id=workspace_id, account_id=account_id, role=role)
self._access.membership_changed(membership, operator_account_id)
return WorkspaceMembership(workspace_id, account_id, role.value)
def assign_role(self, workspace_id: str, operator_id: str, member_id: str, role_id: str) -> None:
self._access.assign_role(workspace_id, operator_id, member_id, role_id)
def remove(self, workspace_id: str, account_id: str, operator_id: str) -> None:
"""Remove a member and transfer their resources to the workspace owner.
Raises:
AccountNotFoundError: The member account does not exist.
CannotOperateSelfError: The operator tries to remove themselves.
NoPermissionError: The operator cannot remove the member.
MemberNotInTenantError: The account is not a member of the workspace.
ValueError: The workspace has no owner to receive the member's resources.
"""
if self._accounts.get(account_id) is None:
raise AccountNotFoundError()
self.check_permission(workspace_id, operator_id, account_id, "remove")
if self.get_role(workspace_id, account_id) is None:
raise MemberNotInTenantError("Member not in tenant.")
owner_id = (
self._access.owner_id(workspace_id, operator_id)
if self._access.rbac_enabled
else self._workspaces.owner_id(workspace_id)
)
if owner_id is None:
raise ValueError(f"Workspace owner not found for tenant {workspace_id}.")
removal = self._workspaces.remove_member(workspace_id=workspace_id, account_id=account_id, owner_id=owner_id)
self._access.member_removed(workspace_id, removal)
def update_role(self, workspace_id: str, member_id: str, new_role: str, operator_id: str) -> None:
if not TenantAccountRole.is_valid_role(new_role):
raise InvalidWorkspaceMemberRoleError()
self._access.ensure_role_enabled(new_role)
if self._accounts.get(member_id) is None:
raise AccountNotFoundError()
self.check_permission(workspace_id, operator_id, member_id, "update")
role = TenantAccountRole(new_role)
previous = self.get_role(workspace_id, member_id)
if previous is None:
raise MemberNotInTenantError("Member not in tenant.")
# RBAC ownership can be ahead of local membership after a failed local commit.
if previous == TenantAccountRole.OWNER or (
self._access.rbac_enabled and self._access.is_owner(workspace_id, operator_id, member_id)
):
raise NoPermissionError("No permission to update member.")
if role == TenantAccountRole.OWNER and (
self.get_role(workspace_id, operator_id) != TenantAccountRole.OWNER
or (self._access.rbac_enabled and not self._access.is_owner(workspace_id, operator_id, operator_id))
):
raise NoPermissionError("No permission to update member.")
if previous == role:
raise RoleAlreadyAssignedError("The provided role is already assigned to the member.")
if self._access.rbac_enabled:
if role == TenantAccountRole.OWNER:
self._access.transfer_owner(workspace_id, operator_id, member_id)
else:
self._access.change_role(workspace_id, operator_id, member_id, role)
self._workspaces.update_member_role(workspace_id=workspace_id, account_id=member_id, role=role)
class InvitedAccountRegistration(Protocol):
def get_account_by_id(self, account_id: str) -> AccountSnapshot | None: ...
def get_account_by_email_with_case_fallback(self, email: str) -> AccountSnapshot | None: ...
def register(
self,
email: str,
name: str,
*,
language: str | None,
status: str,
is_setup: bool,
check_normalized_email: bool,
) -> AccountSnapshot: ...
class InvitationWorkspaceStore(Protocol):
def get(self, workspace_id: str) -> WorkspaceSnapshot | None: ...
def switch(self, *, account_id: str, workspace_id: str) -> WorkspaceSnapshot: ...
def count_members(self, workspace_id: str) -> int: ...
class WorkspaceInvitationDelivery(Protocol):
@property
def requires_capacity_check(self) -> bool: ...
def acquire(self, workspace_id: str) -> AbstractContextManager[object]: ...
def ensure_role_enabled(self, role: str) -> None: ...
def check_capacity(
self, workspace_id: str, *, current_members: int, new_members: int, new_accounts: int
) -> None: ...
def ensure_allowed(self, workspace_id: str) -> None: ...
def check_invitation_quota(self, workspace_id: str) -> None: ...
def create(self, invitation: InvitationToken) -> str: ...
def send(self, *, language: str, email: str, token: str, inviter_name: str, workspace_name: str) -> None: ...
class WorkspaceInvitationService:
def __init__(
self,
*,
accounts: InvitedAccountRegistration,
workspaces: InvitationWorkspaceStore,
members: WorkspaceMemberService,
invitations: WorkspaceInvitationDelivery,
) -> None:
self._accounts = accounts
self._workspaces = workspaces
self._members = members
self._invitations = invitations
def check_invitation_quota(self, context: RequestContext) -> None:
self._invitations.check_invitation_quota(context.active_workspace_id)
def invite_many(
self, context: RequestContext, *, emails: Sequence[str], language: str | None, role: str
) -> tuple[WorkspaceInvitationResult, ...]:
if not self._members.rbac_enabled and (not TenantAccountRole.is_valid_role(role) or role == "owner"):
raise InvalidWorkspaceMemberRoleError()
self._invitations.ensure_role_enabled(role)
workspace_id = context.active_workspace_id
self._invitations.ensure_allowed(workspace_id)
results: list[WorkspaceInvitationResult] = []
with self._invitations.acquire(workspace_id):
if self._invitations.requires_capacity_check:
new_members, new_accounts = 0, 0
for email in emails:
account = self._accounts.get_account_by_email_with_case_fallback(email)
if account is None:
new_accounts += 1
new_members += 1
elif self._members.get_role(workspace_id, account.id) is None:
new_members += 1
if new_members:
self._invitations.check_capacity(
workspace_id,
current_members=self._workspaces.count_members(workspace_id),
new_members=new_members,
new_accounts=new_accounts,
)
for email in emails:
try:
invitation = self.invite(context, email, language, role)
results.append(WorkspaceInvitationResult(email, "success", token=invitation.token))
except AccountAlreadyInTenantError:
results.append(
WorkspaceInvitationResult(email, "already_member", message="Account already in workspace.")
)
except Exception as error:
results.append(WorkspaceInvitationResult(email, "failed", message=str(error)))
return tuple(results)
def invite(
self,
context: RequestContext,
email: str,
language: str | None,
role: str = "normal",
) -> WorkspaceInvitation:
workspace_id = context.active_workspace_id
inviter_id = context.account_id
inviter = self._accounts.get_account_by_id(inviter_id)
workspace = self._workspaces.get(workspace_id)
if inviter is None:
raise ValueError("Inviter is required")
if workspace is None:
raise WorkspaceNotFoundError()
self._invitations.ensure_allowed(workspace_id)
normalized_email = email.lower()
join_role = TenantAccountRole.NORMAL if self._members.rbac_enabled else TenantAccountRole(role)
account = self._accounts.get_account_by_email_with_case_fallback(email)
requires_setup = False
if account is None:
self._members.check_permission(workspace_id, inviter_id, None, "add")
account = self._accounts.register(
email=normalized_email,
name=normalized_email.split("@")[0],
language=language,
status="pending",
is_setup=True,
check_normalized_email=True,
)
self._members.join_member(
workspace_id=workspace_id,
account_id=account.id,
email=account.email,
role=join_role,
operator_account_id=inviter_id,
)
self._workspaces.switch(account_id=account.id, workspace_id=workspace_id)
requires_setup = True
else:
self._members.check_permission(workspace_id, inviter_id, account.id, "add")
previous_role = self._members.get_role(workspace_id, account.id)
requires_setup = account.status == "pending"
if previous_role is None and (requires_setup or self._members.rbac_enabled):
self._members.join_member(
workspace_id=workspace_id,
account_id=account.id,
email=account.email,
role=join_role,
operator_account_id=inviter_id,
)
if account.status != "pending":
if self._members.rbac_enabled and previous_role is None:
self._members.assign_role(workspace_id, inviter_id, account.id, role)
if previous_role is not None or self._members.rbac_enabled:
raise AccountAlreadyInTenantError("Account already in tenant.")
if self._members.rbac_enabled:
self._members.assign_role(workspace_id, inviter_id, account.id, role)
token = self._invitations.create(InvitationToken(account.id, account.email, workspace_id, role, requires_setup))
self._invitations.send(
language=account.interface_language or "en-US",
email=account.email,
token=token,
inviter_name=inviter.name,
workspace_name=workspace.name,
)
return WorkspaceInvitation(workspace_id, account.id, normalized_email, role, token)
class OwnerTransferGateway(Protocol):
def is_ip_limited(self, ip_address: str) -> bool: ...
def is_verification_limited(self, email: str) -> bool: ...
def record_verification_failure(self, email: str) -> None: ...
def reset_verification_failures(self, email: str) -> None: ...
def send_confirmation(self, account: AccountSnapshot, workspace_name: str, language: str) -> str: ...
def read_token(self, token: str) -> OwnerTransferToken | None: ...
def issue_token(self, token: OwnerTransferToken) -> str: ...
def revoke_token(self, token: str) -> None: ...
def notify(self, *, old_owner: AccountSnapshot, new_owner: AccountSnapshot, workspace_name: str) -> None: ...
class WorkspaceOwnerTransferService:
def __init__(
self,
*,
accounts: AccountSnapshotQuery,
workspaces: WorkspaceMemberStore,
members: WorkspaceMemberService,
challenges: OwnerTransferGateway,
) -> None:
self._accounts = accounts
self._workspaces = workspaces
self._members = members
self._challenges = challenges
def _owner(self, context: RequestContext) -> AccountSnapshot:
if self._members.get_role(context.active_workspace_id, context.account_id) != TenantAccountRole.OWNER:
raise NoPermissionError("Only the workspace owner can transfer ownership.")
owner = self._accounts.get(context.account_id)
if owner is None:
raise AccountNotFoundError()
return owner
def send_code(self, context: RequestContext, *, ip_address: str, language: str | None) -> str:
if self._challenges.is_ip_limited(ip_address):
raise OwnerTransferSendIPLimitedError()
owner = self._owner(context)
workspace = self._workspaces.get(context.active_workspace_id)
if workspace is None:
raise WorkspaceNotFoundError()
return self._challenges.send_confirmation(
owner, workspace.name, "zh-Hans" if language == "zh-Hans" else "en-US"
)
def verify_code(self, context: RequestContext, *, token: str, code: str) -> tuple[str, str]:
owner = self._owner(context)
if self._challenges.is_verification_limited(owner.email):
raise OwnerTransferVerificationLimitError()
challenge = self._challenge(token, owner.email)
if challenge.code != code:
self._challenges.record_verification_failure(owner.email)
raise InvalidOwnerTransferCodeError()
self._challenges.revoke_token(token)
new_token = self._challenges.issue_token(OwnerTransferToken(owner.email, code))
self._challenges.reset_verification_failures(owner.email)
return owner.email, new_token
def _challenge(self, token: str, email: str) -> OwnerTransferToken:
challenge = self._challenges.read_token(token)
if challenge is None:
raise InvalidOwnerTransferTokenError()
if challenge.email != email:
raise InvalidOwnerTransferEmailError()
return challenge
def transfer(self, context: RequestContext, *, member_id: str, token: str) -> None:
owner = self._owner(context)
if owner.id == member_id:
raise CannotOperateSelfError("Cannot transfer ownership to self.")
self._challenge(token, owner.email)
self._challenges.revoke_token(token)
member = self._accounts.get(member_id)
if member is None:
raise AccountNotFoundError()
if self._members.get_role(context.active_workspace_id, member_id) is None:
raise MemberNotInTenantError("The new owner is not a workspace member.")
workspace = self._workspaces.get(context.active_workspace_id)
if workspace is None:
raise WorkspaceNotFoundError()
self._members.update_role(workspace.id, member.id, "owner", owner.id)
self._challenges.notify(old_owner=owner, new_owner=member, workspace_name=workspace.name)
@@ -0,0 +1,196 @@
"""Internal workspace creation and membership use cases."""
from collections.abc import Callable
from typing import Protocol
from enums.account import TenantAccountRole
from services.account.contracts import AccountCreation
from services.account_errors import AccountNormalizedEmailAlreadyInUseError, AccountNotFoundError
from services.entities.account_entities import AccountSnapshot
from services.errors.workspace import (
InvalidWorkspaceMemberRoleError,
WorkSpaceNotAllowedCreateError,
WorkspaceOwnerNotFoundError,
WorkspacesLimitExceededError,
)
from services.workspace.contracts import CreatedWorkspace, WorkspaceCreation, WorkspaceMembership
class WorkspaceOwnerQuery(Protocol):
def find_first_by_email(self, email: str) -> AccountSnapshot | None: ...
def get(self, account_id: str) -> AccountSnapshot | None: ...
def normalized_email_exists(self, email: str) -> bool: ...
class WorkspaceCreationPolicy(Protocol):
def is_workspace_creation_allowed(self) -> bool: ...
def has_workspace_capacity(self) -> bool: ...
class WorkspaceMembershipQuery(Protocol):
def list_ids_for_account(self, account_id: str) -> tuple[str, ...]: ...
def has_active_for_account(self, account_id: str) -> bool: ...
class WorkspaceProvisioningStore(Protocol):
def provision(
self,
prepare: Callable[[], WorkspaceCreation],
*,
owner_id: str | None,
account: AccountCreation | None = None,
only_if_no_active_workspace: bool = False,
) -> CreatedWorkspace | None:
"""Run preparation after eligibility checks, holding any required owner lock through commit."""
...
class WorkspaceProvisioningEffects(Protocol):
def prepare(self, name: str) -> WorkspaceCreation: ...
def bind_owner(self, workspace_id: str, account_id: str) -> None: ...
def created(self, workspace: CreatedWorkspace, *, owner_id: str | None) -> None: ...
def join_default_workspace(self, account_id: str) -> None: ...
class WorkspaceMemberWriter(Protocol):
def join_member(
self,
*,
workspace_id: str,
account_id: str,
email: str,
role: TenantAccountRole,
operator_account_id: str | None,
) -> WorkspaceMembership: ...
class WorkspaceProvisioningService:
def __init__(
self,
*,
owners: WorkspaceOwnerQuery,
provisioning: WorkspaceProvisioningStore,
effects: WorkspaceProvisioningEffects,
policies: WorkspaceCreationPolicy,
memberships: WorkspaceMembershipQuery,
members: WorkspaceMemberWriter,
) -> None:
self._owners = owners
self._provisioning = provisioning
self._effects = effects
self._policies = policies
self._memberships = memberships
self._members = members
def create(self, *, name: str, owner_email: str | None = None) -> CreatedWorkspace:
owner_id = None
if owner_email is not None:
owner = self._owners.find_first_by_email(owner_email)
if owner is None:
raise WorkspaceOwnerNotFoundError()
owner_id = owner.id
if not self._policies.has_workspace_capacity():
raise WorkspacesLimitExceededError()
workspace = self._create(name=name, owner_id=owner_id)
assert workspace is not None
return workspace
def create_owner_workspace(
self, account_id: str, *, name: str | None = None, is_setup: bool = False, if_missing: bool = False
) -> None:
if if_missing and self._memberships.list_ids_for_account(account_id):
return
if not is_setup and not self._policies.is_workspace_creation_allowed():
raise WorkSpaceNotAllowedCreateError()
if not self._policies.has_workspace_capacity():
raise WorkspacesLimitExceededError()
owner = self._owners.get(account_id)
if owner is None:
raise AccountNotFoundError()
self._create(name=name or f"{owner.name}'s Workspace", owner_id=owner.id)
def _create(
self,
*,
name: str,
owner_id: str | None,
account: AccountCreation | None = None,
only_if_no_active_workspace: bool = False,
) -> CreatedWorkspace | None:
def prepare() -> WorkspaceCreation:
creation = self._effects.prepare(name)
if owner_id is not None:
self._effects.bind_owner(creation.id, owner_id)
return creation
workspace = self._provisioning.provision(
prepare,
owner_id=owner_id,
account=account,
only_if_no_active_workspace=only_if_no_active_workspace,
)
if workspace is not None:
self._effects.created(workspace, owner_id=owner_id)
return workspace
def create_with_owner_workspace(
self,
*,
email: str,
name: str,
interface_language: str,
timezone: str,
ip_address: str,
) -> str:
if self._owners.normalized_email_exists(email):
raise AccountNormalizedEmailAlreadyInUseError
account = AccountCreation(
email=email,
name=name,
interface_language=interface_language,
interface_theme="light",
timezone=timezone,
ip_address=ip_address,
check_normalized_email=True,
)
self._create(name=f"{name}'s Workspace", owner_id=account.id, account=account)
self._effects.join_default_workspace(account.id)
return account.id
def ensure_owner_workspace(self, account_id: str) -> None:
owner = self._owners.get(account_id)
if owner is None:
raise AccountNotFoundError
if self._memberships.has_active_for_account(account_id):
return
self._create(name=f"{owner.name}'s Workspace", owner_id=account_id, only_if_no_active_workspace=True)
def join_member(
self,
*,
workspace_id: str,
account_id: str,
email: str,
role: str,
operator_account_id: str | None,
) -> WorkspaceMembership:
try:
member_role = TenantAccountRole(role)
except ValueError as exc:
raise InvalidWorkspaceMemberRoleError("invalid workspace member role.") from exc
if member_role == TenantAccountRole.OWNER:
raise InvalidWorkspaceMemberRoleError("cannot join workspace as owner.")
return self._members.join_member(
workspace_id=workspace_id,
account_id=account_id,
email=email,
role=member_role,
operator_account_id=operator_account_id,
)
+223
View File
@@ -0,0 +1,223 @@
"""Workspace management and listing use cases with application-owned ports."""
from collections.abc import Iterator, Mapping, Sequence
from typing import Protocol
from enums import CloudPlan
from machinery.context import RequestContext
from services.errors.file import UnsupportedFileTypeError
from services.errors.workspace import WorkspaceArchivedError, WorkspaceNotFoundError
from services.workspace.contracts import (
EffectiveCreditPool,
WorkspaceCustomConfig,
WorkspaceCustomConfigChanges,
WorkspaceFeatures,
WorkspacePage,
WorkspacePermission,
WorkspaceRecord,
WorkspaceSnapshot,
WorkspaceSummary,
)
class WorkspaceStore(Protocol):
def get(self, workspace_id: str) -> WorkspaceSnapshot | None: ...
def get_many(self, workspace_ids: Sequence[str]) -> tuple[WorkspaceSnapshot, ...]: ...
def list_memberships(self, account_id: str) -> tuple[WorkspaceSnapshot, ...]: ...
def find_membership(self, account_id: str, workspace_id: str) -> WorkspaceSnapshot | None: ...
def list_member_ids(self, workspace_id: str, *, offset: int, limit: int) -> tuple[str, ...]: ...
def list_all(self, *, page: int, limit: int) -> WorkspacePage: ...
def get_for_account(self, workspace_id: str, account_id: str) -> WorkspaceSnapshot | None: ...
def switch(self, *, account_id: str, workspace_id: str) -> WorkspaceSnapshot: ...
def rename(self, *, workspace_id: str, account_id: str, name: str) -> WorkspaceSnapshot: ...
def update_custom_config(
self, *, workspace_id: str, account_id: str, changes: WorkspaceCustomConfig
) -> WorkspaceSnapshot: ...
class WorkspaceFeatureGateway(Protocol):
def get_features(self, workspace_id: str) -> WorkspaceFeatures: ...
def get_effective_credit_pool(self, workspace_id: str) -> EffectiveCreditPool: ...
def logo_url(self, workspace_id: str) -> str: ...
def get_permission(self, workspace_id: str) -> WorkspacePermission: ...
class WorkspaceLogoGateway(Protocol):
def upload(self, context: RequestContext, *, filename: str, content: bytes, mimetype: str) -> str: ...
class WorkspaceService:
def __init__(
self, *, workspaces: WorkspaceStore, features: WorkspaceFeatureGateway, logos: WorkspaceLogoGateway
) -> None:
self._workspaces = workspaces
self._features = features
self._logos = logos
def get(self, workspace_id: str) -> WorkspaceSnapshot | None:
return self._workspaces.get(workspace_id)
def get_many(self, workspace_ids: Sequence[str]) -> tuple[WorkspaceSnapshot, ...]:
return self._workspaces.get_many(workspace_ids)
def list_memberships(self, account_id: str) -> tuple[WorkspaceSnapshot, ...]:
return self._workspaces.list_memberships(account_id)
def find_membership(self, account_id: str, workspace_id: str) -> WorkspaceSnapshot | None:
return self._workspaces.find_membership(account_id, workspace_id)
def switch_membership(self, account_id: str, workspace_id: str) -> WorkspaceSnapshot:
return self._workspaces.switch(account_id=account_id, workspace_id=workspace_id)
def iter_member_account_id_batches(self, workspace_id: str, batch_size: int) -> Iterator[tuple[str, ...]]:
if batch_size <= 0:
raise ValueError("batch_size must be positive")
offset = 0
while account_ids := self._workspaces.list_member_ids(workspace_id, offset=offset, limit=batch_size):
yield account_ids
offset += len(account_ids)
def list_all(self, *, page: int, limit: int) -> WorkspacePage:
return self._workspaces.list_all(page=page, limit=limit)
def _current(self, context: RequestContext) -> WorkspaceSnapshot:
workspace = self._workspaces.get_for_account(context.active_workspace_id, context.account_id)
if workspace is None:
raise WorkspaceNotFoundError()
return workspace
def current_summary(self, context: RequestContext) -> dict[str, object]:
workspace = self._current(context)
if workspace.status == "archive":
raise WorkspaceArchivedError()
credits = self.get_effective_credit_pool(workspace.id)
return {
"id": workspace.id,
"name": workspace.name,
"role": workspace.role,
"plan": credits.plan,
"credits": credits.remaining_credits,
}
def get_effective_credit_pool(self, workspace_id: str) -> EffectiveCreditPool:
return self._features.get_effective_credit_pool(workspace_id)
def switch(self, context: RequestContext, workspace_id: str) -> dict[str, object]:
workspace = self._workspaces.switch(account_id=context.account_id, workspace_id=workspace_id)
return self._tenant_info(workspace)
def custom_config(self, context: RequestContext) -> WorkspaceCustomConfig:
return self._public_config(self._current(context))
def update_custom_config(self, context: RequestContext, changes: WorkspaceCustomConfigChanges) -> dict[str, object]:
current = self._current(context).custom_config
config = WorkspaceCustomConfig(
remove_webapp_brand=changes.remove_webapp_brand
if changes.remove_webapp_brand is not None
else current.remove_webapp_brand,
replace_webapp_logo=changes.replace_webapp_logo
if changes.replace_webapp_logo is not None
else current.replace_webapp_logo,
)
workspace = self._workspaces.update_custom_config(
workspace_id=context.active_workspace_id, account_id=context.account_id, changes=config
)
return self._tenant_info(workspace)
def rename(self, context: RequestContext, name: str) -> dict[str, object]:
workspace = self._workspaces.rename(
workspace_id=context.active_workspace_id, account_id=context.account_id, name=name
)
return self._tenant_info(workspace)
def upload_logo(self, context: RequestContext, *, filename: str, content: bytes, mimetype: str) -> str:
if filename.split(".")[-1].lower() not in {"svg", "png"}:
raise UnsupportedFileTypeError()
return self._logos.upload(context, filename=filename, content=content, mimetype=mimetype)
def permission(self, context: RequestContext) -> WorkspacePermission:
return self._features.get_permission(context.active_workspace_id)
def _public_config(self, workspace: WorkspaceSnapshot) -> WorkspaceCustomConfig:
return WorkspaceCustomConfig(
remove_webapp_brand=workspace.custom_config.remove_webapp_brand,
replace_webapp_logo=self._features.logo_url(workspace.id)
if workspace.custom_config.replace_webapp_logo
else None,
)
def _tenant_info(self, workspace: WorkspaceSnapshot) -> dict[str, object]:
# Repository writes have committed and closed before Feature/Billing I/O.
features = self._features.get_features(workspace.id)
credits = features.credits
info: dict[str, object] = {
"id": workspace.id,
"name": workspace.name,
"status": workspace.status,
"created_at": workspace.created_at,
"role": workspace.role,
"plan": credits.plan,
"trial_end_reason": None,
}
# Preserve the existing workspace-wide privileged-member check.
if features.can_replace_logo and workspace.has_privileged_member:
info["custom_config"] = self._public_config(workspace)
if credits.plan is not None:
info["next_credit_reset_date"] = credits.next_credit_reset_date
if credits.quota_limit is not None:
info["trial_credits"] = credits.quota_limit
info["trial_credits_used"] = credits.quota_used
if credits.exhausted_at is not None:
info["trial_credits_exhausted_at"] = credits.exhausted_at
return info
class WorkspacePlanGateway(Protocol):
def resolve_many(self, workspace_ids: Sequence[str]) -> Mapping[str, str]: ...
class WorkspaceQuery(Protocol):
def list_for_account(self, account_id: str) -> Sequence[WorkspaceRecord]: ...
class WorkspaceQueryService:
def __init__(
self,
*,
workspaces: WorkspaceQuery,
plans: WorkspacePlanGateway,
) -> None:
self._workspaces = workspaces
self._plans = plans
def list_for_account(self, context: RequestContext) -> tuple[WorkspaceSummary, ...]:
records = tuple(self._workspaces.list_for_account(context.account_id))
# The repository closes its read Session before plan resolution
# performs Billing/Feature I/O.
plans = self._plans.resolve_many([record.id for record in records])
return tuple(
WorkspaceSummary(
id=record.id,
name=record.name,
plan=plans.get(record.id, CloudPlan.SANDBOX),
status=record.status,
created_at=record.created_at,
last_opened_at=record.last_opened_at,
current=record.id == context.active_workspace_id,
)
for record in records
)
@@ -1,94 +0,0 @@
"""Application service for listing members of the active Console workspace."""
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import NamedTuple, Protocol
from machinery.context import RequestContext
class WorkspaceMemberRole(NamedTuple):
id: str
name: str
class WorkspaceMemberRecord(NamedTuple):
id: str
name: str
email: str
avatar: str | None
last_login_at: datetime | None
last_active_at: datetime
created_at: datetime
status: str
legacy_role: str
class WorkspaceMemberQuery(Protocol):
def list_for_workspace(self, workspace_id: str) -> Sequence[WorkspaceMemberRecord]: ...
class WorkspaceMemberRoleSubject(NamedTuple):
account_id: str
legacy_role: str
class WorkspaceMemberRoleResolver(Protocol):
def resolve_many(
self,
workspace_id: str,
actor_account_id: str,
subjects: Sequence[WorkspaceMemberRoleSubject],
) -> Mapping[str, Sequence[WorkspaceMemberRole]]: ...
class WorkspaceMemberSummary(NamedTuple):
id: str
name: str
email: str
avatar: str | None
last_login_at: datetime | None
last_active_at: datetime
created_at: datetime
role: str
roles: tuple[WorkspaceMemberRole, ...]
status: str
class WorkspaceMemberQueryService:
def __init__(
self,
*,
members: WorkspaceMemberQuery,
roles: WorkspaceMemberRoleResolver,
) -> None:
self._members = members
self._roles = roles
def list_current(self, context: RequestContext) -> tuple[WorkspaceMemberSummary, ...]:
workspace_id = context.active_workspace_id
records = tuple(self._members.list_for_workspace(workspace_id))
role_subjects = tuple(
WorkspaceMemberRoleSubject(account_id=record.id, legacy_role=record.legacy_role) for record in records
)
# The repository closes its read Session before role resolution
# performs enterprise I/O.
roles_by_member = self._roles.resolve_many(workspace_id, context.account_id, role_subjects)
return tuple(
WorkspaceMemberSummary(
id=record.id,
name=record.name,
email=record.email,
avatar=record.avatar,
last_login_at=record.last_login_at,
last_active_at=record.last_active_at,
created_at=record.created_at,
role=record.legacy_role,
roles=tuple(roles_by_member.get(record.id, ())),
status=record.status,
)
for record in records
)
@@ -1,43 +0,0 @@
"""Deployment-compatible role resolution for workspace-member queries."""
from collections.abc import Mapping, Sequence
from typing import override
from configs import dify_config
from services.enterprise import rbac_service as enterprise_rbac_service
from services.workspace_member_query_service import (
WorkspaceMemberRole,
WorkspaceMemberRoleResolver,
WorkspaceMemberRoleSubject,
)
class DeploymentWorkspaceMemberRoleResolver(WorkspaceMemberRoleResolver):
"""Preserve deployment-specific legacy and enterprise role behavior."""
@override
def resolve_many(
self,
workspace_id: str,
actor_account_id: str,
subjects: Sequence[WorkspaceMemberRoleSubject],
) -> Mapping[str, Sequence[WorkspaceMemberRole]]:
role_subjects = tuple(subjects)
if not role_subjects:
return {}
if not dify_config.RBAC_ENABLED:
return {
subject.account_id: (WorkspaceMemberRole(id=subject.legacy_role, name=subject.legacy_role),)
for subject in role_subjects
}
member_roles = enterprise_rbac_service.RBACService.MemberRoles.batch_get(
workspace_id,
actor_account_id,
[subject.account_id for subject in role_subjects],
)
return {
item.account_id: tuple(WorkspaceMemberRole(id=role.id, name=role.name) for role in item.roles)
for item in member_roles
}
-44
View File
@@ -1,44 +0,0 @@
"""Deployment-aware plan gateway for workspace queries."""
import logging
from collections.abc import Mapping, Sequence
from typing import override
from configs import dify_config
from enums import CloudPlan, DeploymentEdition
from services.billing_service import BillingService
from services.feature_service import FeatureService
from services.workspace_query_service import WorkspacePlanGateway
logger = logging.getLogger(__name__)
class DeploymentWorkspacePlanGateway(WorkspacePlanGateway):
"""Resolve workspace plans using deployment-specific Billing and Feature sources."""
@override
def resolve_many(self, workspace_ids: Sequence[str]) -> Mapping[str, str]:
ids = tuple(workspace_ids)
if not ids:
return {}
is_enterprise_only = dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE
if is_enterprise_only:
return dict.fromkeys(ids, str(CloudPlan.SANDBOX))
is_saas = dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD
bulk_plans = BillingService.get_plan_bulk(ids) if is_saas else {}
if is_saas and not bulk_plans:
logger.warning("get_plan_bulk returned empty result, falling back to FeatureService")
resolved: dict[str, str] = {}
for workspace_id in ids:
tenant_plan = bulk_plans.get(workspace_id)
if tenant_plan:
resolved[workspace_id] = tenant_plan["plan"] or CloudPlan.SANDBOX
continue
features = FeatureService.get_features(workspace_id, exclude_vector_space=True)
resolved[workspace_id] = features.billing.subscription.plan or CloudPlan.SANDBOX
return resolved
-65
View File
@@ -1,65 +0,0 @@
"""Application service for listing workspaces visible to a Console account."""
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import NamedTuple, Protocol
from enums import CloudPlan
from machinery.context import RequestContext
class WorkspacePlanGateway(Protocol):
def resolve_many(self, workspace_ids: Sequence[str]) -> Mapping[str, str]: ...
class WorkspaceRecord(NamedTuple):
id: str
name: str | None
status: str
created_at: datetime
last_opened_at: datetime | None
class WorkspaceQuery(Protocol):
def list_for_account(self, account_id: str) -> Sequence[WorkspaceRecord]: ...
class WorkspaceSummary(NamedTuple):
id: str
name: str | None
plan: str
status: str
created_at: datetime
last_opened_at: datetime | None
current: bool
class WorkspaceQueryService:
def __init__(
self,
*,
workspaces: WorkspaceQuery,
plans: WorkspacePlanGateway,
) -> None:
self._workspaces = workspaces
self._plans = plans
def list_for_account(self, context: RequestContext) -> tuple[WorkspaceSummary, ...]:
records = tuple(self._workspaces.list_for_account(context.account_id))
# The repository closes its read Session before plan resolution
# performs Billing/Feature I/O.
plans = self._plans.resolve_many([record.id for record in records])
return tuple(
WorkspaceSummary(
id=record.id,
name=record.name,
plan=plans.get(record.id, CloudPlan.SANDBOX),
status=record.status,
created_at=record.created_at,
last_opened_at=record.last_opened_at,
current=record.id == context.active_workspace_id,
)
for record in records
)
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff. Show more