mirror of
https://github.com/langgenius/dify.git
synced 2026-05-12 15:58:19 +08:00
feat: invite member support rbac
This commit is contained in:
parent
110442b4b2
commit
d0185ebbef
@ -39,7 +39,7 @@ DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
||||
|
||||
class MemberInvitePayload(BaseModel):
|
||||
emails: list[str] = Field(default_factory=list)
|
||||
role: TenantAccountRole
|
||||
role: str
|
||||
language: str | None = None
|
||||
|
||||
|
||||
@ -149,8 +149,9 @@ class MemberInviteEmailApi(Resource):
|
||||
invitee_emails = args.emails
|
||||
invitee_role = args.role
|
||||
interface_language = args.language
|
||||
if not TenantAccountRole.is_non_owner_role(invitee_role):
|
||||
return {"code": "invalid-role", "message": "Invalid role"}, 400
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
if not TenantAccountRole.is_valid_role(invitee_role) or not TenantAccountRole.is_non_owner_role(invitee_role):
|
||||
return {"code": "invalid-role", "message": "Invalid role"}, 400
|
||||
current_user, _ = current_account_with_tenant()
|
||||
inviter = current_user
|
||||
if not inviter.current_tenant:
|
||||
|
||||
@ -61,6 +61,7 @@ from services.errors.account import (
|
||||
TenantNotFoundError,
|
||||
)
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
|
||||
from services.enterprise.rbac_service import RBACService
|
||||
from services.feature_service import FeatureService
|
||||
from tasks.delete_account_task import delete_account_task
|
||||
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
|
||||
@ -1545,8 +1546,9 @@ class RegisterService:
|
||||
status=AccountStatus.PENDING,
|
||||
is_setup=True,
|
||||
)
|
||||
# Create new tenant member for invited tenant
|
||||
TenantService.create_tenant_member(tenant, account, role)
|
||||
# Create new tenant member for invited tenant (legacy path)
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
TenantService.create_tenant_member(tenant, account, role)
|
||||
TenantService.switch_tenant(account, tenant.id)
|
||||
else:
|
||||
TenantService.check_member_permission(tenant, inviter, account, "add")
|
||||
@ -1557,12 +1559,22 @@ class RegisterService:
|
||||
)
|
||||
|
||||
if not ta:
|
||||
TenantService.create_tenant_member(tenant, account, role)
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
TenantService.create_tenant_member(tenant, account, role)
|
||||
|
||||
# Support resend invitation email when the account is pending status
|
||||
if account.status != AccountStatus.PENDING:
|
||||
raise AccountAlreadyInTenantError("Account already in tenant.")
|
||||
|
||||
# Assign RBAC role if RBAC is enabled
|
||||
if dify_config.RBAC_ENABLED:
|
||||
RBACService.MemberRoles.replace(
|
||||
tenant_id=str(tenant.id),
|
||||
account_id=inviter.id,
|
||||
member_account_id=account.id,
|
||||
role_ids=[role],
|
||||
)
|
||||
|
||||
token = cls.generate_invite_token(tenant, account)
|
||||
language = account.interface_language or "en-US"
|
||||
|
||||
|
||||
@ -52,7 +52,9 @@ class TestMemberInviteEmailApi:
|
||||
inviter = SimpleNamespace(email="Owner@Example.com", current_tenant=tenant, status="active")
|
||||
mock_current_account.return_value = (inviter, tenant.id)
|
||||
|
||||
with patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "https://console.example.com"):
|
||||
with patch("controllers.console.workspace.members.dify_config") as mock_config:
|
||||
mock_config.RBAC_ENABLED = False
|
||||
mock_config.CONSOLE_WEB_URL = "https://console.example.com"
|
||||
with app.test_request_context(
|
||||
"/workspaces/current/members/invite-email",
|
||||
method="POST",
|
||||
@ -75,3 +77,116 @@ class TestMemberInviteEmailApi:
|
||||
assert call_args.kwargs["role"] == TenantAccountRole.EDITOR
|
||||
assert call_args.kwargs["inviter"] == inviter
|
||||
mock_csrf.assert_called_once()
|
||||
|
||||
@patch("controllers.console.workspace.members.FeatureService.get_features")
|
||||
@patch("controllers.console.workspace.members.RegisterService.invite_new_member")
|
||||
@patch("controllers.console.workspace.members.current_account_with_tenant")
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("libs.login.check_csrf_token", return_value=None)
|
||||
def test_invite_rbac_enabled_accepts_rbac_role_id(
|
||||
self,
|
||||
mock_csrf,
|
||||
mock_db,
|
||||
mock_current_account,
|
||||
mock_invite_member,
|
||||
mock_get_features,
|
||||
app,
|
||||
):
|
||||
"""When RBAC is enabled, any non-empty role string should be accepted."""
|
||||
mock_get_features.return_value = _build_feature_flags()
|
||||
mock_invite_member.return_value = "rbac-token"
|
||||
|
||||
tenant = SimpleNamespace(id="tenant-1", name="Test Tenant")
|
||||
inviter = SimpleNamespace(email="inviter@example.com", current_tenant=tenant, status="active")
|
||||
mock_current_account.return_value = (inviter, tenant.id)
|
||||
|
||||
with patch("controllers.console.workspace.members.dify_config") as mock_config:
|
||||
mock_config.RBAC_ENABLED = True
|
||||
mock_config.CONSOLE_WEB_URL = "https://console.example.com"
|
||||
with app.test_request_context(
|
||||
"/workspaces/current/members/invite-email",
|
||||
method="POST",
|
||||
json={"emails": ["user@example.com"], "role": "rbac-role-id-abc", "language": "en-US"},
|
||||
):
|
||||
account = Account(name="tester", email="tester@example.com")
|
||||
account._current_tenant = tenant
|
||||
g._login_user = account
|
||||
g._current_tenant = tenant
|
||||
response, status_code = MemberInviteEmailApi().post()
|
||||
|
||||
assert status_code == 201
|
||||
mock_invite_member.assert_called_once()
|
||||
call_args = mock_invite_member.call_args
|
||||
assert call_args.kwargs["role"] == "rbac-role-id-abc"
|
||||
|
||||
@patch("controllers.console.workspace.members.FeatureService.get_features")
|
||||
@patch("controllers.console.workspace.members.current_account_with_tenant")
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("libs.login.check_csrf_token", return_value=None)
|
||||
def test_invite_rbac_disabled_rejects_invalid_role(
|
||||
self,
|
||||
mock_csrf,
|
||||
mock_db,
|
||||
mock_current_account,
|
||||
mock_get_features,
|
||||
app,
|
||||
):
|
||||
"""When RBAC is disabled, an invalid role string should be rejected."""
|
||||
mock_get_features.return_value = _build_feature_flags()
|
||||
|
||||
tenant = SimpleNamespace(id="tenant-1", name="Test Tenant")
|
||||
inviter = SimpleNamespace(email="inviter@example.com", current_tenant=tenant, status="active")
|
||||
mock_current_account.return_value = (inviter, tenant.id)
|
||||
|
||||
with patch("controllers.console.workspace.members.dify_config") as mock_config:
|
||||
mock_config.RBAC_ENABLED = False
|
||||
mock_config.CONSOLE_WEB_URL = "https://console.example.com"
|
||||
with app.test_request_context(
|
||||
"/workspaces/current/members/invite-email",
|
||||
method="POST",
|
||||
json={"emails": ["user@example.com"], "role": "invalid-role", "language": "en-US"},
|
||||
):
|
||||
account = Account(name="tester", email="tester@example.com")
|
||||
account._current_tenant = tenant
|
||||
g._login_user = account
|
||||
g._current_tenant = tenant
|
||||
response, status_code = MemberInviteEmailApi().post()
|
||||
|
||||
assert status_code == 400
|
||||
assert response["code"] == "invalid-role"
|
||||
|
||||
@patch("controllers.console.workspace.members.FeatureService.get_features")
|
||||
@patch("controllers.console.workspace.members.current_account_with_tenant")
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("libs.login.check_csrf_token", return_value=None)
|
||||
def test_invite_rbac_disabled_rejects_owner_role(
|
||||
self,
|
||||
mock_csrf,
|
||||
mock_db,
|
||||
mock_current_account,
|
||||
mock_get_features,
|
||||
app,
|
||||
):
|
||||
"""When RBAC is disabled, owner role should be rejected for invite."""
|
||||
mock_get_features.return_value = _build_feature_flags()
|
||||
|
||||
tenant = SimpleNamespace(id="tenant-1", name="Test Tenant")
|
||||
inviter = SimpleNamespace(email="inviter@example.com", current_tenant=tenant, status="active")
|
||||
mock_current_account.return_value = (inviter, tenant.id)
|
||||
|
||||
with patch("controllers.console.workspace.members.dify_config") as mock_config:
|
||||
mock_config.RBAC_ENABLED = False
|
||||
mock_config.CONSOLE_WEB_URL = "https://console.example.com"
|
||||
with app.test_request_context(
|
||||
"/workspaces/current/members/invite-email",
|
||||
method="POST",
|
||||
json={"emails": ["user@example.com"], "role": "owner", "language": "en-US"},
|
||||
):
|
||||
account = Account(name="tester", email="tester@example.com")
|
||||
account._current_tenant = tenant
|
||||
g._login_user = account
|
||||
g._current_tenant = tenant
|
||||
response, status_code = MemberInviteEmailApi().post()
|
||||
|
||||
assert status_code == 400
|
||||
assert response["code"] == "invalid-role"
|
||||
|
||||
@ -1608,6 +1608,136 @@ class TestRegisterService:
|
||||
inviter=None,
|
||||
)
|
||||
|
||||
# ==================== RBAC Member Invitation Tests ====================
|
||||
|
||||
def test_invite_new_member_rbac_enabled_new_account(
|
||||
self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies
|
||||
):
|
||||
"""When RBAC is enabled, create_tenant_member should be skipped and MemberRoles.replace called."""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-789"
|
||||
mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-456", name="Inviter")
|
||||
|
||||
with (
|
||||
patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup,
|
||||
patch("services.account_service.dify_config") as mock_config,
|
||||
):
|
||||
mock_lookup.return_value = None
|
||||
mock_config.RBAC_ENABLED = True
|
||||
|
||||
mock_new_account = TestAccountAssociatedDataFactory.create_account_mock(
|
||||
account_id="new-user-rbac", email="rbac@example.com", name="rbacuser", status="pending"
|
||||
)
|
||||
with (
|
||||
patch("services.account_service.RegisterService.register") as mock_register,
|
||||
patch("services.account_service.TenantService.check_member_permission"),
|
||||
patch("services.account_service.TenantService.create_tenant_member") as mock_create_member,
|
||||
patch("services.account_service.TenantService.switch_tenant"),
|
||||
patch("services.account_service.RegisterService.generate_invite_token", return_value="rbac-token"),
|
||||
patch("services.account_service.RBACService") as mock_rbac_service,
|
||||
):
|
||||
mock_register.return_value = mock_new_account
|
||||
|
||||
result = RegisterService.invite_new_member(
|
||||
tenant=mock_tenant,
|
||||
email="rbac@example.com",
|
||||
language="en-US",
|
||||
role="rbac-role-id-123",
|
||||
inviter=mock_inviter,
|
||||
)
|
||||
|
||||
assert result == "rbac-token"
|
||||
mock_create_member.assert_not_called()
|
||||
mock_rbac_service.MemberRoles.replace.assert_called_once_with(
|
||||
tenant_id=str(mock_tenant.id),
|
||||
account_id=mock_inviter.id,
|
||||
member_account_id=mock_new_account.id,
|
||||
role_ids=["rbac-role-id-123"],
|
||||
)
|
||||
|
||||
def test_invite_new_member_rbac_enabled_existing_account(
|
||||
self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies
|
||||
):
|
||||
"""When RBAC is enabled and account exists, create_tenant_member should be skipped and MemberRoles.replace called."""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-789"
|
||||
mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-456", name="Inviter")
|
||||
mock_existing_account = TestAccountAssociatedDataFactory.create_account_mock(
|
||||
account_id="existing-rbac", email="existing-rbac@example.com", status="pending"
|
||||
)
|
||||
|
||||
mock_db_dependencies["db"].session.scalar.return_value = None
|
||||
|
||||
with (
|
||||
patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup,
|
||||
patch("services.account_service.dify_config") as mock_config,
|
||||
):
|
||||
mock_lookup.return_value = mock_existing_account
|
||||
mock_config.RBAC_ENABLED = True
|
||||
|
||||
with (
|
||||
patch("services.account_service.TenantService.check_member_permission"),
|
||||
patch("services.account_service.TenantService.create_tenant_member") as mock_create_member,
|
||||
patch("services.account_service.RegisterService.generate_invite_token", return_value="rbac-token"),
|
||||
patch("services.account_service.RBACService") as mock_rbac_service,
|
||||
):
|
||||
result = RegisterService.invite_new_member(
|
||||
tenant=mock_tenant,
|
||||
email="existing-rbac@example.com",
|
||||
language="en-US",
|
||||
role="rbac-role-id-456",
|
||||
inviter=mock_inviter,
|
||||
)
|
||||
|
||||
assert result == "rbac-token"
|
||||
mock_create_member.assert_not_called()
|
||||
mock_rbac_service.MemberRoles.replace.assert_called_once_with(
|
||||
tenant_id=str(mock_tenant.id),
|
||||
account_id=mock_inviter.id,
|
||||
member_account_id=mock_existing_account.id,
|
||||
role_ids=["rbac-role-id-456"],
|
||||
)
|
||||
|
||||
def test_invite_new_member_rbac_disabled_uses_legacy_role(
|
||||
self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies
|
||||
):
|
||||
"""When RBAC is disabled, create_tenant_member should be called and MemberRoles.replace should NOT."""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-legacy"
|
||||
mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-789", name="Inviter")
|
||||
|
||||
with (
|
||||
patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup,
|
||||
patch("services.account_service.dify_config") as mock_config,
|
||||
):
|
||||
mock_lookup.return_value = None
|
||||
mock_config.RBAC_ENABLED = False
|
||||
|
||||
mock_new_account = TestAccountAssociatedDataFactory.create_account_mock(
|
||||
account_id="legacy-user", email="legacy@example.com", name="legacyuser", status="pending"
|
||||
)
|
||||
with (
|
||||
patch("services.account_service.RegisterService.register") as mock_register,
|
||||
patch("services.account_service.TenantService.check_member_permission"),
|
||||
patch("services.account_service.TenantService.create_tenant_member") as mock_create_member,
|
||||
patch("services.account_service.TenantService.switch_tenant"),
|
||||
patch("services.account_service.RegisterService.generate_invite_token", return_value="legacy-token"),
|
||||
patch("services.account_service.RBACService") as mock_rbac_service,
|
||||
):
|
||||
mock_register.return_value = mock_new_account
|
||||
|
||||
result = RegisterService.invite_new_member(
|
||||
tenant=mock_tenant,
|
||||
email="legacy@example.com",
|
||||
language="en-US",
|
||||
role="editor",
|
||||
inviter=mock_inviter,
|
||||
)
|
||||
|
||||
assert result == "legacy-token"
|
||||
mock_create_member.assert_called_once_with(mock_tenant, mock_new_account, "editor")
|
||||
mock_rbac_service.MemberRoles.replace.assert_not_called()
|
||||
|
||||
# ==================== Token Management Tests ====================
|
||||
|
||||
def test_generate_invite_token_success(self, mock_redis_dependencies):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user