mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
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:
1 parent
6decc78fd5
commit
95373bc4fd
251 files changed
+9691
-14618
No files matched your search
+31
-13
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -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
|
||||
+40
-96
@@ -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)
|
||||
File renamed without changes.
+1
-1
@@ -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,
|
||||
+3
@@ -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.
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
@@ -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."""
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Workspace domain modules."""
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
Reference in new issue
Block a user