feat: invite member support rbac

This commit is contained in:
fatelei 2026-05-12 13:35:22 +08:00
parent 110442b4b2
commit d0185ebbef
No known key found for this signature in database
GPG Key ID: 2F91DA05646F4EED
4 changed files with 265 additions and 7 deletions

View File

@ -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:

View File

@ -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"

View File

@ -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"

View File

@ -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):