关于 Dify 平台多租户的几项测试
关于 Dify 平台多租户的几项测试
- 测试1:创建多个账户后,只保留自己的工作区,关闭多租户创建功能。(实现对多工作区的权限管控)
- 测试2:创建账号后,在数据库删除工作区ID,使其失去拥有完全权限的工作区。
- 测试3:多账户登录,多个账号同时登录是否有影响。
- 探索1:编写shell脚本替换所有Dify文字和图标。
- 探索2:编写程序实现对docker中数据库的读写,实现独立程序对Dify应用的权限管控。
环境说明:
系统版本:Linux ubuntu24 6.8.0-51-generic #52-Ubuntu SMP PREEMPT_DYNAMIC Thu Dec 5 13:09:44 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux
Dify 版本:0.15.3
浏览器版本:Version 131.0.6778.264 (Official Build) (64-bit)
一、测试1:多工作区的开启与关闭
1.1 使用 docker 启动 dify
1.2 创建用户验证无多租户功能
创建新用户:
新用户登录(无新工作区):
1.3 从 docker 更改源代码开启多租户
执行 sudo docker exec -it docker-api-1 /bin/bash
进入容器命令行
执行 apt update && apt install -y vim
安装编辑器
执行 services/account_service.py
编辑文件增加创建工作区的代码(增加.filter_by(role="owner")
,意思是自己账户下没有工作区的时候就创建一个)
执行 vim services/feature_service.py
编辑文件开启创建工作区的开关(将此变量改成True,图中是拷贝了一行再修改的,也可以直接修改)
1.4 重启容器
执行 sudo docker restart docker-api-1
重启
1.5 创建新用户验证多租户开启
创建新用户:
登录新用户发现已经有自己的工作区:
二、测试2: 操作数据库记录对 Dify 平台的影响
可操作 accounts 表,控制账户的信息和登录使能。
可操作 tenants 表,控制工作区。
三、测试3:多端登录是否可行
3.1 创建新用户 test3login@example.com
- 设置中创建新用户
- 访问邀请链接并设置秘密
- 设置用户名和界面语言等信息
3.2 不同环境同时登录该用户
设置用户名后会自动登录该账号,为避免受创建用户首次登录的特殊性。将此账号退出后,在此浏览器重新登录。然后使用另一电脑的浏览器访问该服务,使用此账号登录。经验证,两个账号可以同时保持登录状态,不会出现冲突下线情况。
3.3 不同环境同时用此用户进行流程操作
分别在两台电脑登录此账号,在A电脑创建一个工作流,在B电脑打开此工作流,然后同时进行编辑,经验证,数据以最后执行保存操作时所在机器上的数据为准(即覆盖)。 如果A电脑进行了内容更新,如果进行另比如发布、退出应用等会执行保存的操作后,B电脑刷新页面可更新到最新数据(比如工作流中刷新页面,会显示A电脑进行保存后的页面,但之前此电脑进行的操作会丢失),如果B电脑没有刷新而进行了保存操作,则会覆盖掉A电脑上进行的操作。
3.4 根据源代码确认
- /login api 匹配的登录函数
# /api/controllers/console/auth/login.py
from controllers.console import api
class LoginApi(Resource):
"""Resource for user login."""
@setup_required
def post(self):
"""Authenticate user and login."""
parser = reqparse.RequestParser()
parser.add_argument("email", type=email, required=True, location="json")
parser.add_argument("password", type=valid_password, required=True, location="json")
parser.add_argument("remember_me", type=bool, required=False, default=False, location="json")
parser.add_argument("invite_token", type=str, required=False, default=None, location="json")
parser.add_argument("language", type=str, required=False, default="en-US", location="json")
args = parser.parse_args()
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(args["email"]):
raise AccountInFreezeError()
is_login_error_rate_limit = AccountService.is_login_error_rate_limit(args["email"])
if is_login_error_rate_limit:
raise EmailPasswordLoginLimitError()
invitation = args["invite_token"]
if invitation:
invitation = RegisterService.get_invitation_if_token_valid(None, args["email"], invitation)
if args["language"] is not None and args["language"] == "zh-Hans":
language = "zh-Hans"
else:
language = "en-US"
try:
if invitation:
data = invitation.get("data", {})
invitee_email = data.get("email") if data else None
if invitee_email != args["email"]:
raise InvalidEmailError()
account = AccountService.authenticate(args["email"], args["password"], args["invite_token"])
else:
account = AccountService.authenticate(args["email"], args["password"])
except services.errors.account.AccountLoginError:
raise AccountBannedError()
except services.errors.account.AccountPasswordError:
AccountService.add_login_error_rate_limit(args["email"])
raise EmailOrPasswordMismatchError()
except services.errors.account.AccountNotFoundError:
if FeatureService.get_system_features().is_allow_register:
token = AccountService.send_reset_password_email(email=args["email"], language=language)
return {"result": "fail", "data": token, "code": "account_not_found"}
else:
raise AccountNotFound()
# SELF_HOSTED only have one workspace
tenants = TenantService.get_join_tenants(account)
if len(tenants) == 0:
return {
"result": "fail",
"data": "workspace not found, please contact system admin to invite you to join in a workspace",
}
token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(args["email"])
return {"result": "success", "data": token_pair.model_dump()}
api.add_resource(LoginApi, "/login")
- 认证方法函数体
# /api/services/account_service.py
class AccountService:
reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1)
email_code_login_rate_limiter = RateLimiter(
prefix="email_code_login_rate_limit", max_attempts=1, time_window=60 * 1
)
email_code_account_deletion_rate_limiter = RateLimiter(
prefix="email_code_account_deletion_rate_limit", max_attempts=1, time_window=60 * 1
)
LOGIN_MAX_ERROR_LIMITS = 5
@staticmethod
def get_account_jwt_token(account: Account) -> str:
exp_dt = datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES)
exp = int(exp_dt.timestamp())
payload = {
"user_id": account.id,
"exp": exp,
"iss": dify_config.EDITION,
"sub": "Console API Passport",
}
token: str = PassportService().issue(payload)
return token
@staticmethod
def authenticate(email: str, password: str, invite_token: Optional[str] = None) -> Account:
"""authenticate account with email and password"""
account = Account.query.filter_by(email=email).first()
if not account:
raise AccountNotFoundError()
if account.status == AccountStatus.BANNED.value:
raise AccountLoginError("Account is banned.")
if password and invite_token and account.password is None:
# if invite_token is valid, set password and password_salt
salt = secrets.token_bytes(16)
base64_salt = base64.b64encode(salt).decode()
password_hashed = hash_password(password, salt)
base64_password_hashed = base64.b64encode(password_hashed).decode()
account.password = base64_password_hashed
account.password_salt = base64_salt
if account.password is None or not compare_password(password, account.password, account.password_salt):
raise AccountPasswordError("Invalid email or password.")
if account.status == AccountStatus.PENDING.value:
account.status = AccountStatus.ACTIVE.value
account.initialized_at = datetime.now(UTC).replace(tzinfo=None)
db.session.commit()
return cast(Account, account)
@staticmethod
def update_login_info(account: Account, *, ip_address: str) -> None:
"""Update last login time and ip"""
account.last_login_at = datetime.now(UTC).replace(tzinfo=None)
account.last_login_ip = ip_address
db.session.add(account)
db.session.commit()
@staticmethod
def login(account: Account, *, ip_address: Optional[str] = None) -> TokenPair:
if ip_address:
AccountService.update_login_info(account=account, ip_address=ip_address)
if account.status == AccountStatus.PENDING.value:
account.status = AccountStatus.ACTIVE.value
db.session.commit()
access_token = AccountService.get_account_jwt_token(account=account)
refresh_token = _generate_refresh_token()
AccountService._store_refresh_token(refresh_token, account.id)
return TokenPair(access_token=access_token, refresh_token=refresh_token)
上述文件中没有看到限制多端登录的代码,结合实际测试,认为 Dify 可以多端登录。
登录相关概念补充:
单地登录:指一个账号同一时间只能在一个地方登录,新登录会挤掉旧登录,也可以叫:单端登录。
多地登录:指一个账号同一时间可以在不同地方登录,新登录会和旧登录共存,也可以叫:多端登录。
同端互斥登录:在同一类型设备上只允许单地点登录,在不同类型设备上允许同时在线,参考腾讯QQ的登录模式:手机和电脑可以同时在线,但不能两个手机同时在线。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· .NET10 - 预览版1新功能体验(一)
2020-02-16 二维码,QR码,编码原理与实现
2020-02-16 关于域名申请、注册、购买、解析、续费的问题
2020-02-16 天翼网关获取超级密码