diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index f8a3005dd6..ae1b7965d6 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -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: diff --git a/api/services/account_service.py b/api/services/account_service.py index b6554a3de7..cda71b5c1d 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -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" diff --git a/api/tests/unit_tests/controllers/console/test_workspace_members.py b/api/tests/unit_tests/controllers/console/test_workspace_members.py index 412d6a6c52..4f73a5b8c5 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_members.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_members.py @@ -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" diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index e9d2f1481e..9e5b936f96 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -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):