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:
Escape0707 2026-08-06 10:08:39 +09:00 committed by GitHub
parent 18f740402f
commit b6b3e747f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 733 additions and 144 deletions

View File

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

View File

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

View File

@ -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(),
),
)

View 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

View 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
)

View 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
}

View File

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

View File

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

View File

@ -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 == ()

View File

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

View File

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