mirror of
https://github.com/langgenius/dify.git
synced 2026-08-15 04:59:46 +08:00
refactor(api): extract workspace member listing into layered service (#39974)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
18f740402f
commit
b6b3e747f3
@ -41,3 +41,20 @@ forbidden_modules =
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:workspace-member-query-service-boundary]
|
||||
name = Workspace member query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.workspace_member_query_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.enterprise
|
||||
services.workspace_member_role_resolver
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
@ -24,6 +24,7 @@ from controllers.console.auth.error import (
|
||||
OwnerTransferLimitError,
|
||||
)
|
||||
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,
|
||||
@ -31,15 +32,16 @@ from controllers.console.wraps import (
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
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 current_account_with_tenant, login_required
|
||||
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.enterprise import rbac_service as enterprise_rbac_service
|
||||
from services.errors.account import AccountAlreadyInTenantError
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
@ -144,22 +146,6 @@ def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool:
|
||||
return FeatureService.get_features(tenant_id=tenant_id, exclude_vector_space=True).dataset_operator_enabled
|
||||
|
||||
|
||||
def _serialize_member_roles(
|
||||
current_role: str | None, member_roles: list[enterprise_rbac_service.RBACRole]
|
||||
) -> list[dict[str, str]]:
|
||||
if dify_config.RBAC_ENABLED:
|
||||
return [{"id": role.id, "name": role.name} for role in member_roles]
|
||||
else:
|
||||
if current_role:
|
||||
return [{"id": current_role, "name": current_role}]
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_enum_value(value: object) -> str:
|
||||
normalized = getattr(value, "value", value)
|
||||
return str(normalized) if normalized is not None else ""
|
||||
|
||||
|
||||
def _count_new_member_invites(tenant_id: str, emails: list[str]) -> tuple[int, int]:
|
||||
new_member_count = 0
|
||||
new_account_count = 0
|
||||
@ -214,46 +200,25 @@ def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_accou
|
||||
class MemberListApi(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 | None = None):
|
||||
if current_user is None:
|
||||
current_user, _ = current_account_with_tenant()
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
members = TenantService.get_tenant_members(current_user.current_tenant, session=db.session())
|
||||
if dify_config.RBAC_ENABLED:
|
||||
member_ids = [member.id for member in members]
|
||||
member_roles = enterprise_rbac_service.RBACService.MemberRoles.batch_get(
|
||||
str(current_user.current_tenant.id),
|
||||
current_user.id,
|
||||
member_ids,
|
||||
)
|
||||
roles_map = {item.account_id: item.roles for item in member_roles}
|
||||
else:
|
||||
roles_map = {}
|
||||
|
||||
serialized_members = []
|
||||
for member in members:
|
||||
current_role = _normalize_enum_value(member.current_role)
|
||||
serialized_members.append(
|
||||
{
|
||||
"id": member.id,
|
||||
"name": member.name,
|
||||
"email": member.email,
|
||||
"avatar": member.avatar,
|
||||
"last_login_at": member.last_login_at,
|
||||
"last_active_at": member.last_active_at,
|
||||
"created_at": member.created_at,
|
||||
"role": current_role,
|
||||
"roles": _serialize_member_roles(current_role, roles_map.get(member.id, [])),
|
||||
"status": _normalize_enum_value(member.status),
|
||||
}
|
||||
)
|
||||
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
members = application_services().workspace_member_queries.list_current(request_context)
|
||||
serialized_members = [
|
||||
{
|
||||
"id": member.id,
|
||||
"name": member.name,
|
||||
"email": member.email,
|
||||
"avatar": member.avatar,
|
||||
"last_login_at": member.last_login_at,
|
||||
"last_active_at": member.last_active_at,
|
||||
"created_at": member.created_at,
|
||||
"role": member.role,
|
||||
"roles": [{"id": role.id, "name": role.name} for role in member.roles],
|
||||
"status": member.status,
|
||||
}
|
||||
for member in members
|
||||
]
|
||||
return dump_response(AccountWithRoleListResponse, {"accounts": serialized_members}), HTTPStatus.OK
|
||||
|
||||
|
||||
|
||||
@ -7,7 +7,10 @@ from flask import Flask, current_app
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.db.session_factory import get_session_maker
|
||||
from repositories.workspace_member_query_repository import WorkspaceMemberQueryRepository
|
||||
from repositories.workspace_query_repository import WorkspaceQueryRepository
|
||||
from services.workspace_member_query_service import WorkspaceMemberQueryService
|
||||
from services.workspace_member_role_resolver import DeploymentWorkspaceMemberRoleResolver
|
||||
from services.workspace_query_compat import LegacyWorkspacePlanGateway
|
||||
from services.workspace_query_service import WorkspaceQueryService
|
||||
|
||||
@ -17,6 +20,7 @@ _EXTENSION_KEY = "application_services"
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationServices:
|
||||
workspace_queries: WorkspaceQueryService
|
||||
workspace_member_queries: WorkspaceMemberQueryService
|
||||
|
||||
|
||||
def build_application_services(
|
||||
@ -29,7 +33,13 @@ def build_application_services(
|
||||
client=database_client,
|
||||
),
|
||||
plans=LegacyWorkspacePlanGateway(),
|
||||
)
|
||||
),
|
||||
workspace_member_queries=WorkspaceMemberQueryService(
|
||||
members=WorkspaceMemberQueryRepository(
|
||||
session_factory=database_client,
|
||||
),
|
||||
roles=DeploymentWorkspaceMemberRoleResolver(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
62
api/repositories/workspace_member_query_repository.py
Normal file
62
api/repositories/workspace_member_query_repository.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""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
|
||||
96
api/services/workspace_member_query_service.py
Normal file
96
api/services/workspace_member_query_service.py
Normal file
@ -0,0 +1,96 @@
|
||||
"""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
|
||||
if workspace_id is None:
|
||||
raise RuntimeError("Console account admission did not resolve an active workspace")
|
||||
|
||||
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
|
||||
)
|
||||
43
api/services/workspace_member_role_resolver.py
Normal file
43
api/services/workspace_member_role_resolver.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""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
|
||||
}
|
||||
@ -81,14 +81,12 @@ class TestMemberInviteEmailApi:
|
||||
|
||||
@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,
|
||||
@ -98,8 +96,6 @@ class TestMemberInviteEmailApi:
|
||||
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
|
||||
@ -121,14 +117,12 @@ class TestMemberInviteEmailApi:
|
||||
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,
|
||||
):
|
||||
@ -136,8 +130,6 @@ class TestMemberInviteEmailApi:
|
||||
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
|
||||
@ -158,14 +150,12 @@ class TestMemberInviteEmailApi:
|
||||
assert exc_info.value.data == {"code": "invalid_role", "message": "Invalid role.", "status": 400}
|
||||
|
||||
@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,
|
||||
):
|
||||
@ -173,8 +163,6 @@ class TestMemberInviteEmailApi:
|
||||
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
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
from contextlib import nullcontext
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from typing import NamedTuple, override
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@ -29,92 +32,93 @@ from controllers.console.workspace.members import (
|
||||
_count_new_member_invites,
|
||||
)
|
||||
from libs.external_api import ExternalApi
|
||||
from machinery.context import RequestContext
|
||||
from services.errors.account import AccountAlreadyInTenantError, SeatsLimitExceededError
|
||||
from services.workspace_member_query_service import (
|
||||
WorkspaceMemberQueryService,
|
||||
WorkspaceMemberRole,
|
||||
WorkspaceMemberSummary,
|
||||
)
|
||||
|
||||
|
||||
class _RecordingWorkspaceMemberQueryService(WorkspaceMemberQueryService):
|
||||
def __init__(self, result: tuple[WorkspaceMemberSummary, ...]) -> None:
|
||||
self._result = result
|
||||
self.contexts: list[RequestContext] = []
|
||||
|
||||
@override
|
||||
def list_current(self, context: RequestContext) -> tuple[WorkspaceMemberSummary, ...]:
|
||||
self.contexts.append(context)
|
||||
return self._result
|
||||
|
||||
|
||||
class _ApplicationServicesStub(NamedTuple):
|
||||
workspace_member_queries: WorkspaceMemberQueryService
|
||||
|
||||
|
||||
class TestMemberListApi:
|
||||
def test_get_success(self, app: Flask):
|
||||
def test_get_passes_context_and_serializes_application_result(self, app: Flask) -> None:
|
||||
api = MemberListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
tenant = MagicMock()
|
||||
user = MagicMock(current_tenant=tenant)
|
||||
member = MagicMock()
|
||||
member.id = "m1"
|
||||
member.name = "Member"
|
||||
member.email = "member@test.com"
|
||||
member.avatar = "avatar.png"
|
||||
member.current_role = SimpleNamespace(value="admin")
|
||||
member.status = SimpleNamespace(value="active")
|
||||
members = [member]
|
||||
request_context = RequestContext(
|
||||
request_id="request-1",
|
||||
trace_id="trace-1",
|
||||
account_id="actor-1",
|
||||
active_workspace_id="workspace-1",
|
||||
)
|
||||
timestamp = datetime(2026, 1, 1)
|
||||
workspace_member_queries = _RecordingWorkspaceMemberQueryService(
|
||||
(
|
||||
WorkspaceMemberSummary(
|
||||
id="member-1",
|
||||
name="Member",
|
||||
email="member@example.com",
|
||||
avatar=None,
|
||||
last_login_at=None,
|
||||
last_active_at=timestamp,
|
||||
created_at=timestamp,
|
||||
role="owner",
|
||||
roles=(
|
||||
WorkspaceMemberRole(id="workspace.owner", name="Owner"),
|
||||
WorkspaceMemberRole(id="workspace.editor", name="Editor"),
|
||||
),
|
||||
status="active",
|
||||
),
|
||||
)
|
||||
)
|
||||
application_services_stub = _ApplicationServicesStub(workspace_member_queries=workspace_member_queries)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch("controllers.console.workspace.members.TenantService.get_tenant_members", return_value=members),
|
||||
):
|
||||
result, status = method(api, user)
|
||||
|
||||
assert status == 200
|
||||
assert len(result["accounts"]) == 1
|
||||
assert result["accounts"][0]["role"] == "admin"
|
||||
assert result["accounts"][0]["roles"] == [{"id": "admin", "name": "admin"}]
|
||||
|
||||
def test_get_with_rbac_enabled_fetches_roles_in_batch(self, app):
|
||||
api = MemberListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
tenant = MagicMock(id="tenant-1")
|
||||
user = MagicMock(id="acct-1", current_tenant=tenant)
|
||||
member = SimpleNamespace(
|
||||
id="m1",
|
||||
name="Member",
|
||||
email="member@test.com",
|
||||
avatar=None,
|
||||
last_login_at=1,
|
||||
last_active_at=2,
|
||||
created_at=3,
|
||||
current_role=SimpleNamespace(value="editor"),
|
||||
status=SimpleNamespace(value="active"),
|
||||
)
|
||||
role_item = SimpleNamespace(
|
||||
account_id="m1",
|
||||
roles=[
|
||||
SimpleNamespace(id="workspace.owner", name="Owner"),
|
||||
SimpleNamespace(id="workspace.editor", name="Editor"),
|
||||
],
|
||||
)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "tenant-1")),
|
||||
patch("controllers.console.workspace.members.dify_config.RBAC_ENABLED", True),
|
||||
patch("controllers.console.workspace.members.TenantService.get_tenant_members", return_value=[member]),
|
||||
patch(
|
||||
"controllers.console.workspace.members.enterprise_rbac_service.RBACService.MemberRoles.batch_get",
|
||||
return_value=[role_item],
|
||||
) as mock_batch_get,
|
||||
"controllers.console.workspace.members.application_services",
|
||||
return_value=application_services_stub,
|
||||
),
|
||||
):
|
||||
result, status = method(api)
|
||||
result, status = method(api, request_context=request_context)
|
||||
|
||||
assert status == 200
|
||||
assert result["accounts"][0]["role"] == "editor"
|
||||
assert result["accounts"][0]["roles"] == [
|
||||
{"id": "workspace.owner", "name": "Owner"},
|
||||
{"id": "workspace.editor", "name": "Editor"},
|
||||
]
|
||||
mock_batch_get.assert_called_once_with("tenant-1", "acct-1", ["m1"])
|
||||
|
||||
def test_get_no_tenant(self, app: Flask):
|
||||
api = MemberListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
user = MagicMock(current_tenant=None)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
method(api, user)
|
||||
assert status == HTTPStatus.OK
|
||||
assert result == {
|
||||
"accounts": [
|
||||
{
|
||||
"id": "member-1",
|
||||
"name": "Member",
|
||||
"email": "member@example.com",
|
||||
"avatar": None,
|
||||
"avatar_url": None,
|
||||
"last_login_at": None,
|
||||
"last_active_at": int(timestamp.timestamp()),
|
||||
"created_at": int(timestamp.timestamp()),
|
||||
"role": "owner",
|
||||
"roles": [
|
||||
{"id": "workspace.owner", "name": "Owner"},
|
||||
{"id": "workspace.editor", "name": "Editor"},
|
||||
],
|
||||
"status": "active",
|
||||
}
|
||||
]
|
||||
}
|
||||
assert workspace_member_queries.contexts == [request_context]
|
||||
|
||||
|
||||
class TestMemberInviteEmailApi:
|
||||
|
||||
@ -0,0 +1,126 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from repositories.workspace_member_query_repository import WorkspaceMemberQueryRepository
|
||||
from services.workspace_member_query_service import WorkspaceMemberRecord
|
||||
|
||||
|
||||
def make_account(
|
||||
account_id: str,
|
||||
*,
|
||||
status: AccountStatus,
|
||||
created_at: datetime,
|
||||
) -> Account:
|
||||
account = Account(
|
||||
name=f"Member {account_id}",
|
||||
email=f"{account_id}@example.com",
|
||||
avatar=f"{account_id}.png",
|
||||
status=status,
|
||||
)
|
||||
account.id = account_id
|
||||
account.last_login_at = created_at
|
||||
account.last_active_at = created_at
|
||||
account.created_at = created_at
|
||||
return account
|
||||
|
||||
|
||||
def make_tenant(tenant_id: str) -> Tenant:
|
||||
tenant = Tenant(name=f"Workspace {tenant_id}")
|
||||
tenant.id = tenant_id
|
||||
return tenant
|
||||
|
||||
|
||||
def test_list_for_workspace_uses_join_membership_and_preserves_account_lifecycle(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
created_at = datetime(2026, 1, 1)
|
||||
active = make_account("active", status=AccountStatus.ACTIVE, created_at=created_at)
|
||||
uninitialized = make_account("uninitialized", status=AccountStatus.UNINITIALIZED, created_at=created_at)
|
||||
pending = make_account("pending", status=AccountStatus.PENDING, created_at=created_at)
|
||||
banned = make_account("banned", status=AccountStatus.BANNED, created_at=created_at)
|
||||
closed = make_account("closed", status=AccountStatus.CLOSED, created_at=created_at)
|
||||
other_workspace_member = make_account("other", status=AccountStatus.ACTIVE, created_at=created_at)
|
||||
unjoined = make_account("unjoined", status=AccountStatus.ACTIVE, created_at=created_at)
|
||||
workspace = make_tenant("workspace-1")
|
||||
other_workspace = make_tenant("workspace-2")
|
||||
|
||||
with sqlite_session_factory() as session:
|
||||
session.add_all(
|
||||
[
|
||||
workspace,
|
||||
other_workspace,
|
||||
active,
|
||||
uninitialized,
|
||||
pending,
|
||||
banned,
|
||||
closed,
|
||||
other_workspace_member,
|
||||
unjoined,
|
||||
TenantAccountJoin(
|
||||
tenant_id=workspace.id,
|
||||
account_id=active.id,
|
||||
role=TenantAccountRole.OWNER,
|
||||
),
|
||||
TenantAccountJoin(
|
||||
tenant_id=workspace.id,
|
||||
account_id=uninitialized.id,
|
||||
role=TenantAccountRole.NORMAL,
|
||||
),
|
||||
TenantAccountJoin(
|
||||
tenant_id=workspace.id,
|
||||
account_id=pending.id,
|
||||
role=TenantAccountRole.NORMAL,
|
||||
),
|
||||
TenantAccountJoin(
|
||||
tenant_id=workspace.id,
|
||||
account_id=banned.id,
|
||||
role=TenantAccountRole.ADMIN,
|
||||
),
|
||||
TenantAccountJoin(
|
||||
tenant_id=workspace.id,
|
||||
account_id=closed.id,
|
||||
role=TenantAccountRole.EDITOR,
|
||||
),
|
||||
TenantAccountJoin(
|
||||
tenant_id=other_workspace.id,
|
||||
account_id=other_workspace_member.id,
|
||||
role=TenantAccountRole.ADMIN,
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
result = WorkspaceMemberQueryRepository(sqlite_session_factory).list_for_workspace(workspace.id)
|
||||
|
||||
by_id = {member.id: member for member in result}
|
||||
assert set(by_id) == {"active", "uninitialized", "pending", "banned", "closed"}
|
||||
assert by_id["active"] == WorkspaceMemberRecord(
|
||||
id=active.id,
|
||||
name=active.name,
|
||||
email=active.email,
|
||||
avatar=active.avatar,
|
||||
last_login_at=created_at,
|
||||
last_active_at=created_at,
|
||||
created_at=created_at,
|
||||
status=AccountStatus.ACTIVE.value,
|
||||
legacy_role=TenantAccountRole.OWNER.value,
|
||||
)
|
||||
assert by_id["uninitialized"].status == AccountStatus.UNINITIALIZED.value
|
||||
assert by_id["pending"].status == AccountStatus.PENDING.value
|
||||
assert by_id["pending"].legacy_role == TenantAccountRole.NORMAL.value
|
||||
assert by_id["banned"].status == AccountStatus.BANNED.value
|
||||
assert by_id["closed"].status == AccountStatus.CLOSED.value
|
||||
|
||||
|
||||
def test_list_for_workspace_returns_empty_tuple_without_membership(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with sqlite_session_factory() as session:
|
||||
session.add(make_tenant("workspace-1"))
|
||||
session.commit()
|
||||
|
||||
result = WorkspaceMemberQueryRepository(sqlite_session_factory).list_for_workspace("workspace-1")
|
||||
|
||||
assert result == ()
|
||||
@ -0,0 +1,150 @@
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from machinery.context import RequestContext
|
||||
from services.workspace_member_query_service import (
|
||||
WorkspaceMemberQueryService,
|
||||
WorkspaceMemberRecord,
|
||||
WorkspaceMemberRole,
|
||||
WorkspaceMemberRoleSubject,
|
||||
WorkspaceMemberSummary,
|
||||
)
|
||||
|
||||
|
||||
def make_context(*, workspace_id: str | None = "workspace-1") -> RequestContext:
|
||||
return RequestContext(
|
||||
request_id="request-1",
|
||||
trace_id="trace-1",
|
||||
account_id="actor-1",
|
||||
active_workspace_id=workspace_id,
|
||||
)
|
||||
|
||||
|
||||
def make_member(
|
||||
member_id: str,
|
||||
*,
|
||||
status: str = "active",
|
||||
legacy_role: str = "normal",
|
||||
) -> WorkspaceMemberRecord:
|
||||
created_at = datetime(2026, 1, 1)
|
||||
return WorkspaceMemberRecord(
|
||||
id=member_id,
|
||||
name=f"Member {member_id}",
|
||||
email=f"{member_id}@example.com",
|
||||
avatar=None,
|
||||
last_login_at=None,
|
||||
last_active_at=created_at,
|
||||
created_at=created_at,
|
||||
status=status,
|
||||
legacy_role=legacy_role,
|
||||
)
|
||||
|
||||
|
||||
class RecordingMemberQuery:
|
||||
def __init__(self, records: Sequence[WorkspaceMemberRecord]) -> None:
|
||||
self.records = tuple(records)
|
||||
self.workspace_ids: list[str] = []
|
||||
|
||||
def list_for_workspace(self, workspace_id: str) -> Sequence[WorkspaceMemberRecord]:
|
||||
self.workspace_ids.append(workspace_id)
|
||||
return self.records
|
||||
|
||||
|
||||
class RecordingRoleResolver:
|
||||
def __init__(self, roles: Mapping[str, Sequence[WorkspaceMemberRole]]) -> None:
|
||||
self.roles = roles
|
||||
self.calls: list[tuple[str, str, tuple[WorkspaceMemberRoleSubject, ...]]] = []
|
||||
|
||||
def resolve_many(
|
||||
self,
|
||||
workspace_id: str,
|
||||
actor_account_id: str,
|
||||
subjects: Sequence[WorkspaceMemberRoleSubject],
|
||||
) -> Mapping[str, Sequence[WorkspaceMemberRole]]:
|
||||
self.calls.append((workspace_id, actor_account_id, tuple(subjects)))
|
||||
return self.roles
|
||||
|
||||
|
||||
class FailingRoleResolver:
|
||||
def resolve_many(
|
||||
self,
|
||||
workspace_id: str,
|
||||
actor_account_id: str,
|
||||
subjects: Sequence[WorkspaceMemberRoleSubject],
|
||||
) -> Mapping[str, Sequence[WorkspaceMemberRole]]:
|
||||
del workspace_id, actor_account_id, subjects
|
||||
raise RoleResolutionError
|
||||
|
||||
|
||||
class RoleResolutionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def test_list_current_projects_members_and_merges_roles_by_account_id() -> None:
|
||||
active = make_member("active", legacy_role="owner")
|
||||
pending = make_member("pending", status="pending")
|
||||
members = RecordingMemberQuery([active, pending])
|
||||
roles = RecordingRoleResolver(
|
||||
{
|
||||
active.id: [
|
||||
WorkspaceMemberRole(id="workspace.owner", name="Owner"),
|
||||
WorkspaceMemberRole(id="workspace.editor", name="Editor"),
|
||||
]
|
||||
}
|
||||
)
|
||||
service = WorkspaceMemberQueryService(members=members, roles=roles)
|
||||
|
||||
result = service.list_current(make_context())
|
||||
|
||||
by_id = {member.id: member for member in result}
|
||||
assert set(by_id) == {"active", "pending"}
|
||||
assert by_id["active"] == WorkspaceMemberSummary(
|
||||
id=active.id,
|
||||
name=active.name,
|
||||
email=active.email,
|
||||
avatar=active.avatar,
|
||||
last_login_at=active.last_login_at,
|
||||
last_active_at=active.last_active_at,
|
||||
created_at=active.created_at,
|
||||
role=active.legacy_role,
|
||||
roles=(
|
||||
WorkspaceMemberRole(id="workspace.owner", name="Owner"),
|
||||
WorkspaceMemberRole(id="workspace.editor", name="Editor"),
|
||||
),
|
||||
status=active.status,
|
||||
)
|
||||
assert by_id["pending"].status == "pending"
|
||||
assert by_id["pending"].roles == ()
|
||||
assert members.workspace_ids == ["workspace-1"]
|
||||
assert roles.calls == [
|
||||
(
|
||||
"workspace-1",
|
||||
"actor-1",
|
||||
(
|
||||
WorkspaceMemberRoleSubject(account_id=active.id, legacy_role=active.legacy_role),
|
||||
WorkspaceMemberRoleSubject(account_id=pending.id, legacy_role=pending.legacy_role),
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_list_current_rejects_missing_workspace_before_calling_ports() -> None:
|
||||
members = RecordingMemberQuery([])
|
||||
roles = RecordingRoleResolver({})
|
||||
service = WorkspaceMemberQueryService(members=members, roles=roles)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Console account admission did not resolve an active workspace"):
|
||||
service.list_current(make_context(workspace_id=None))
|
||||
|
||||
assert members.workspace_ids == []
|
||||
assert roles.calls == []
|
||||
|
||||
|
||||
def test_list_current_propagates_role_resolution_failure() -> None:
|
||||
members = RecordingMemberQuery([make_member("member-1")])
|
||||
service = WorkspaceMemberQueryService(members=members, roles=FailingRoleResolver())
|
||||
|
||||
with pytest.raises(RoleResolutionError):
|
||||
service.list_current(make_context())
|
||||
@ -0,0 +1,128 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from services import workspace_member_role_resolver
|
||||
from services.enterprise.rbac_service import MemberRolesResponse, RBACRole
|
||||
from services.workspace_member_query_service import WorkspaceMemberRole, WorkspaceMemberRoleSubject
|
||||
|
||||
|
||||
def make_subject(account_id: str, *, legacy_role: str = "normal") -> WorkspaceMemberRoleSubject:
|
||||
return WorkspaceMemberRoleSubject(account_id=account_id, legacy_role=legacy_role)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def batch_get(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
||||
batch_get = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
workspace_member_role_resolver.enterprise_rbac_service.RBACService.MemberRoles,
|
||||
"batch_get",
|
||||
batch_get,
|
||||
)
|
||||
return batch_get
|
||||
|
||||
|
||||
def configure_rbac(monkeypatch: pytest.MonkeyPatch, *, enabled: bool) -> None:
|
||||
monkeypatch.setattr(
|
||||
workspace_member_role_resolver,
|
||||
"dify_config",
|
||||
SimpleNamespace(RBAC_ENABLED=enabled),
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_mode_projects_join_roles_without_enterprise_call(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
batch_get: MagicMock,
|
||||
) -> None:
|
||||
configure_rbac(monkeypatch, enabled=False)
|
||||
owner = make_subject("owner", legacy_role="owner")
|
||||
member = make_subject("member")
|
||||
|
||||
result = workspace_member_role_resolver.DeploymentWorkspaceMemberRoleResolver().resolve_many(
|
||||
"workspace-1",
|
||||
"actor-1",
|
||||
[owner, member],
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"owner": (WorkspaceMemberRole(id="owner", name="owner"),),
|
||||
"member": (WorkspaceMemberRole(id="normal", name="normal"),),
|
||||
}
|
||||
batch_get.assert_not_called()
|
||||
|
||||
|
||||
def test_rbac_mode_maps_batch_response_without_legacy_fallback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
batch_get: MagicMock,
|
||||
) -> None:
|
||||
configure_rbac(monkeypatch, enabled=True)
|
||||
owner = make_subject("owner", legacy_role="owner")
|
||||
omitted = make_subject("omitted", legacy_role="admin")
|
||||
batch_get.return_value = [
|
||||
MemberRolesResponse(
|
||||
account_id=owner.account_id,
|
||||
roles=[
|
||||
RBACRole(
|
||||
id="workspace.owner",
|
||||
name="Owner",
|
||||
type="builtin",
|
||||
),
|
||||
RBACRole(
|
||||
id="workspace.editor",
|
||||
name="Editor",
|
||||
type="builtin",
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
result = workspace_member_role_resolver.DeploymentWorkspaceMemberRoleResolver().resolve_many(
|
||||
"workspace-1",
|
||||
"actor-1",
|
||||
[owner, omitted],
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"owner": (
|
||||
WorkspaceMemberRole(id="workspace.owner", name="Owner"),
|
||||
WorkspaceMemberRole(id="workspace.editor", name="Editor"),
|
||||
)
|
||||
}
|
||||
assert "omitted" not in result
|
||||
batch_get.assert_called_once_with("workspace-1", "actor-1", ["owner", "omitted"])
|
||||
|
||||
|
||||
def test_rbac_failure_propagates(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
batch_get: MagicMock,
|
||||
) -> None:
|
||||
configure_rbac(monkeypatch, enabled=True)
|
||||
batch_get.side_effect = RoleResolutionError("enterprise unavailable")
|
||||
|
||||
with pytest.raises(RoleResolutionError, match="enterprise unavailable"):
|
||||
workspace_member_role_resolver.DeploymentWorkspaceMemberRoleResolver().resolve_many(
|
||||
"workspace-1",
|
||||
"actor-1",
|
||||
[make_subject("member-1")],
|
||||
)
|
||||
|
||||
|
||||
def test_empty_member_list_skips_enterprise_call(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
batch_get: MagicMock,
|
||||
) -> None:
|
||||
configure_rbac(monkeypatch, enabled=True)
|
||||
|
||||
result = workspace_member_role_resolver.DeploymentWorkspaceMemberRoleResolver().resolve_many(
|
||||
"workspace-1",
|
||||
"actor-1",
|
||||
[],
|
||||
)
|
||||
|
||||
assert result == {}
|
||||
batch_get.assert_not_called()
|
||||
|
||||
|
||||
class RoleResolutionError(Exception):
|
||||
pass
|
||||
Loading…
Reference in New Issue
Block a user