Files
dify/api/controllers/openapi/workspaces.py
T
GareArc e7d6485641 refactor(openapi): drop the workspace switch op and the console-only current field
difyctl keeps its workspace pin on the device and no longer calls the
server switch, so the op had no caller. `current` on the workspace list
and detail reported the web console's workspace, which read as the one
difyctl uses.
2026-10-14 19:42:47 -07:00

304 lines
12 KiB
Python

"""User-scoped workspace reads and member management under /openapi/v1/workspaces.
Bearer-authed counterparts to the cookie-authed /console/api/workspaces
endpoints. Account bearers (dfoa_) see every tenant they're a member of.
External SSO bearers (dfoe_) have no account_id and so see an empty list —
that matches /openapi/v1/account. Resource access bearers may list only their
own tenant, without inheriting any account membership or role.
Member management declares both authorization arms; ``RBAC_ENABLED`` picks one.
``GET /workspaces/<workspace_id>`` deliberately declares neither: it admits any
account bearer and lets the view's own membership-scoped lookup answer 404.
"""
from __future__ import annotations
from urllib import parse
from uuid import UUID
from flask_restx import Resource
from werkzeug.exceptions import BadRequest, NotFound
from configs import dify_config
from constants.oauth_bearer import Scope
from controllers.common.rbac import RBACCheck, RBACPermission, Workspace
from controllers.openapi import openapi_ns
from controllers.openapi._contract import Example, Kind, endpoint
from controllers.openapi._models import (
MemberActionResponse,
MemberInvitePayload,
MemberInviteResponse,
MemberListQuery,
MemberListResponse,
MemberResponse,
MemberRoleUpdatePayload,
WorkspaceDetailResponse,
WorkspaceListQuery,
WorkspaceListResponse,
WorkspaceSummaryResponse,
)
from controllers.openapi.auth.context import Context
from controllers.openapi.auth.requirements import (
CheckRBACPermission,
CheckScope,
CheckSubject,
CheckWorkspaceInvitationQuota,
CheckWorkspaceMember,
CheckWorkspaceRole,
)
from controllers.openapi.auth.subjects import AccountSubject, ResourceAccessSubject
from enums.account import TenantAccountRole
from extensions.ext_application_services import application_services
from services.account_errors import AccountNotFoundError, AccountRegisterError, SeatsLimitExceededError
from services.errors.base import NoPermissionError
from services.errors.workspace import (
AccountAlreadyInTenantError,
CannotOperateSelfError,
InvalidWorkspaceMemberRoleError,
MemberNotInTenantError,
RoleAlreadyAssignedError,
)
from services.workspace.contracts import WorkspaceInvitation, WorkspaceMemberRecord, WorkspaceSnapshot
def _member_response(account: WorkspaceMemberRecord) -> MemberResponse:
return MemberResponse(
id=account.id,
name=account.name,
email=account.email,
role=account.legacy_role,
status=account.status,
avatar=account.avatar,
)
@openapi_ns.route("/workspaces")
class WorkspacesApi(Resource):
@endpoint(
op="get.workspace",
kind=Kind.LIST,
summary="List workspaces of the current account",
examples=(Example(title="List my workspaces, first page", input={"page": 1, "limit": 20}),),
requirements=(CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)), CheckScope(Scope.WORKSPACE_READ)),
query=WorkspaceListQuery,
returns=(200, WorkspaceListResponse, "Workspace list"),
)
def get(self, ctx: Context, *, query: WorkspaceListQuery):
if isinstance(ctx.subject, ResourceAccessSubject):
tenant = ctx.workspace
item = WorkspaceSummaryResponse(id=str(tenant.id), name=tenant.name, role="", status=tenant.status)
return WorkspaceListResponse.page_of([item], query=query)
rows = application_services().workspaces.management.list_memberships(str(ctx.subject.account_id))
return WorkspaceListResponse.page_of([_workspace_summary(row) for row in rows], query=query)
@openapi_ns.route("/workspaces/<string:workspace_id>")
class WorkspaceByIdApi(Resource):
@endpoint(
op="describe.workspace",
kind=Kind.OBJECT,
summary="Workspace detail",
examples=(
Example(title="Show the pinned workspace", input={}),
Example(title="Show another workspace by id", input={"workspace_id": "<workspace_id>"}),
),
requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.WORKSPACE_READ)),
returns=(200, WorkspaceDetailResponse, "Workspace detail"),
)
def get(self, ctx: Context, workspace_id: str):
row = application_services().workspaces.management.find_membership(str(ctx.subject.account_id), workspace_id)
if row is None:
raise NotFound("workspace not found")
return _workspace_detail(row)
@openapi_ns.route("/workspaces/<string:workspace_id>/members")
class WorkspaceMembersApi(Resource):
"""List + invite members.
GET is any-member. POST requires admin/owner — owner can never be
assigned through invite (ownership transfer is console-only).
"""
@endpoint(
op="get.workspace.member",
kind=Kind.LIST,
summary="List workspace members",
examples=(Example(title="List members of the pinned workspace, first page", input={"page": 1, "limit": 20}),),
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckScope(Scope.WORKSPACE_READ),
CheckWorkspaceMember(),
),
query=MemberListQuery,
returns=(200, MemberListResponse, "Member list"),
)
def get(self, ctx: Context, workspace_id: str, *, query: MemberListQuery):
result = application_services().workspaces.member_queries.list_page(
ctx.request_context, page=query.page, limit=query.limit
)
return MemberListResponse.build(
page=query.page,
limit=query.limit,
total=result.total,
items=[_member_response(m) for m in result.members],
)
@endpoint(
op="invite.workspace.member",
kind=Kind.OBJECT,
summary="Invite a member by email",
examples=(
Example(title="Invite a member as a normal user", input={"email": "ada@example.com", "role": "normal"}),
Example(title="Invite a member as an admin", input={"email": "grace@example.com", "role": "admin"}),
),
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckScope(Scope.WORKSPACE_WRITE),
CheckWorkspaceMember(),
CheckRBACPermission(RBACCheck(RBACPermission.WORKSPACE_MEMBER_MANAGE, Workspace())),
CheckWorkspaceRole(frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})),
CheckWorkspaceInvitationQuota(),
),
body=MemberInvitePayload,
returns=(201, MemberInviteResponse, "Member invited"),
)
def post(self, ctx: Context, workspace_id: str, *, body: MemberInvitePayload):
try:
invitation = application_services().workspaces.invitations.invite(
ctx.request_context,
email=body.email,
language=None,
role=body.role,
)
except AccountAlreadyInTenantError as exc:
raise BadRequest(str(exc))
except InvalidWorkspaceMemberRoleError as exc:
raise BadRequest(str(exc)) from exc
except NoPermissionError as exc:
raise BadRequest(str(exc))
except SeatsLimitExceededError:
raise BadRequest("licensed seats limit exceeded")
except AccountRegisterError as exc:
raise BadRequest(str(exc))
return _invitation_response(invitation)
@openapi_ns.route("/workspaces/<string:workspace_id>/members/<string:member_id>")
class WorkspaceMemberApi(Resource):
"""Remove a member (DELETE) or change a member's role (PATCH).
Self-removal and owner-removal are explicitly rejected by the service
layer (CannotOperateSelfError, NoPermissionError) — both surface as
400 per the spec, with the service's message preserved. Owner can never be
assigned via PATCH (closed enum); admin cannot demote the standing owner.
"""
@endpoint(
op="delete.workspace.member",
kind=Kind.OBJECT,
summary="Remove a member",
examples=(Example(title="Remove a member by account id", input={"member_id": "<member_id>"}),),
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckScope(Scope.WORKSPACE_WRITE),
CheckWorkspaceMember(),
CheckRBACPermission(RBACCheck(RBACPermission.WORKSPACE_MEMBER_MANAGE, Workspace())),
CheckWorkspaceRole(frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})),
),
returns=(200, MemberActionResponse, "Member removed"),
)
def delete(self, ctx: Context, workspace_id: str, member_id: str):
member_id = _parse_member_id(member_id)
try:
application_services().workspaces.members.remove(workspace_id, member_id, str(ctx.subject.account_id))
except AccountNotFoundError:
raise NotFound("member not found") from None
except CannotOperateSelfError as exc:
raise BadRequest(str(exc))
except NoPermissionError as exc:
raise BadRequest(str(exc))
except MemberNotInTenantError as exc:
raise NotFound(str(exc))
return MemberActionResponse()
@endpoint(
op="set.workspace.member_role",
kind=Kind.OBJECT,
summary="Change a member's role",
examples=(
Example(title="Promote a member to admin", input={"member_id": "<member_id>", "role": "admin"}),
Example(title="Demote an admin to a normal member", input={"member_id": "<member_id>", "role": "normal"}),
),
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckScope(Scope.WORKSPACE_WRITE),
CheckWorkspaceMember(),
CheckRBACPermission(RBACCheck(RBACPermission.WORKSPACE_ROLE_MANAGE, Workspace())),
CheckWorkspaceRole(frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})),
),
body=MemberRoleUpdatePayload,
returns=(200, MemberActionResponse, "Role updated"),
)
def patch(self, ctx: Context, workspace_id: str, member_id: str, *, body: MemberRoleUpdatePayload):
member_id = _parse_member_id(member_id)
try:
application_services().workspaces.members.update_role(
workspace_id, member_id, body.role, str(ctx.subject.account_id)
)
except AccountNotFoundError:
raise NotFound("member not found") from None
except CannotOperateSelfError as exc:
raise BadRequest(str(exc))
except NoPermissionError as exc:
raise BadRequest(str(exc))
except MemberNotInTenantError as exc:
raise NotFound(str(exc))
except RoleAlreadyAssignedError as exc:
raise BadRequest(str(exc))
except InvalidWorkspaceMemberRoleError as exc:
raise BadRequest(str(exc)) from exc
return MemberActionResponse()
def _parse_member_id(member_id: str) -> str:
# Permission comparisons must use the same identity as UUID database lookups.
try:
return str(UUID(member_id))
except ValueError:
raise NotFound("member not found") from None
def _invitation_response(invitation: WorkspaceInvitation) -> MemberInviteResponse:
encoded_email = parse.quote(invitation.email)
return MemberInviteResponse(
email=invitation.email,
role=invitation.role,
member_id=invitation.account_id,
invite_url=f"{dify_config.CONSOLE_WEB_URL}/activate?email={encoded_email}&token={invitation.token}",
tenant_id=invitation.workspace_id,
)
def _workspace_summary(tenant: WorkspaceSnapshot) -> WorkspaceSummaryResponse:
return WorkspaceSummaryResponse(
id=tenant.id,
name=tenant.name,
role=tenant.role or "",
status=tenant.status,
)
def _workspace_detail(tenant: WorkspaceSnapshot) -> WorkspaceDetailResponse:
return WorkspaceDetailResponse(
id=tenant.id,
name=tenant.name,
role=tenant.role or "",
status=tenant.status,
created_at=tenant.created_at.isoformat() if tenant.created_at else None,
)