mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:32:01 +08:00
feat(openapi,cli): workspace switch + member management (#36651)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
5c5a6e83e5
commit
6e1e0d9439
@ -37,6 +37,13 @@ from controllers.openapi._models import (
|
||||
DeviceMutateRequest,
|
||||
DeviceMutateResponse,
|
||||
DevicePollRequest,
|
||||
MemberActionResponse,
|
||||
MemberInvitePayload,
|
||||
MemberInviteResponse,
|
||||
MemberListQuery,
|
||||
MemberListResponse,
|
||||
MemberResponse,
|
||||
MemberRoleUpdatePayload,
|
||||
MessageMetadata,
|
||||
PermittedExternalAppsListQuery,
|
||||
PermittedExternalAppsListResponse,
|
||||
@ -63,6 +70,9 @@ register_schema_models(
|
||||
DevicePollRequest,
|
||||
DeviceLookupQuery,
|
||||
DeviceMutateRequest,
|
||||
MemberInvitePayload,
|
||||
MemberListQuery,
|
||||
MemberRoleUpdatePayload,
|
||||
PermittedExternalAppsListQuery,
|
||||
)
|
||||
register_response_schema_models(
|
||||
@ -86,6 +96,10 @@ register_response_schema_models(
|
||||
WorkspaceSummaryResponse,
|
||||
WorkspaceListResponse,
|
||||
WorkspaceDetailResponse,
|
||||
MemberResponse,
|
||||
MemberListResponse,
|
||||
MemberInviteResponse,
|
||||
MemberActionResponse,
|
||||
DeviceCodeResponse,
|
||||
DeviceLookupResponse,
|
||||
DeviceMutateResponse,
|
||||
|
||||
@ -6,7 +6,7 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from libs.helper import UUIDStrOrEmpty, uuid_value
|
||||
from libs.helper import EmailStr, UUIDStrOrEmpty, uuid_value
|
||||
from models.model import AppMode
|
||||
|
||||
# Server-side cap on `limit` query param for /openapi/v1/* list endpoints.
|
||||
@ -342,3 +342,61 @@ class ApprovalGrantClaimsPayload(BaseModel):
|
||||
user_code: str = Field(min_length=1, max_length=32)
|
||||
nonce: str = Field(min_length=1, max_length=128)
|
||||
csrf_token: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
# Closed enum for invite/update-role payloads. Owner is intentionally not
|
||||
# assignable through these endpoints — ownership transfer goes through the
|
||||
# console's three-step email-verification flow.
|
||||
MemberAssignableRole = Literal["normal", "admin"]
|
||||
|
||||
|
||||
class MemberResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
role: str
|
||||
status: str
|
||||
avatar: str | None = None
|
||||
|
||||
|
||||
class MemberListResponse(BaseModel):
|
||||
page: int
|
||||
limit: int
|
||||
total: int
|
||||
has_more: bool
|
||||
data: list[MemberResponse]
|
||||
|
||||
|
||||
class MemberListQuery(BaseModel):
|
||||
"""Strict (extra='forbid')."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
page: int = Field(1, ge=1)
|
||||
limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT)
|
||||
|
||||
|
||||
class MemberInvitePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
email: EmailStr
|
||||
role: MemberAssignableRole
|
||||
|
||||
|
||||
class MemberRoleUpdatePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
role: MemberAssignableRole
|
||||
|
||||
|
||||
class MemberInviteResponse(BaseModel):
|
||||
result: Literal["success"] = "success"
|
||||
email: str
|
||||
role: str
|
||||
member_id: str
|
||||
invite_url: str
|
||||
tenant_id: str
|
||||
|
||||
|
||||
class MemberActionResponse(BaseModel):
|
||||
result: Literal["success"] = "success"
|
||||
|
||||
77
api/controllers/openapi/auth/role_gate.py
Normal file
77
api/controllers/openapi/auth/role_gate.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""Workspace role gate.
|
||||
|
||||
Layered on top of `validate_bearer` + `accept_subjects(SubjectType.ACCOUNT)`
|
||||
for routes whose access depends on the caller's `TenantAccountJoin.role`
|
||||
in the workspace named by the `workspace_id` path parameter.
|
||||
|
||||
Usage::
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/members")
|
||||
class Members(Resource):
|
||||
@validate_bearer(accept=ACCEPT_USER_ANY)
|
||||
@accept_subjects(SubjectType.ACCOUNT)
|
||||
@require_workspace_role() # any member
|
||||
def get(self, workspace_id: str): ...
|
||||
|
||||
@validate_bearer(accept=ACCEPT_USER_ANY)
|
||||
@accept_subjects(SubjectType.ACCOUNT)
|
||||
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
|
||||
def post(self, workspace_id: str): ...
|
||||
|
||||
Non-member callers get 404 (matching `GET /openapi/v1/workspaces/<id>`)
|
||||
so workspace IDs do not leak across tenants. A member without one of the
|
||||
allowed roles gets 403.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import TypeVar
|
||||
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from extensions.ext_database import db
|
||||
from libs.oauth_bearer import try_get_auth_ctx
|
||||
from models.account import TenantAccountRole
|
||||
from services.account_service import TenantService
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., object])
|
||||
|
||||
|
||||
def require_workspace_role(*allowed_roles: TenantAccountRole) -> Callable[[F], F]:
|
||||
"""Gate a route on the caller's role in ``workspace_id``.
|
||||
|
||||
Pass no roles to require only membership. Pass one or more roles to
|
||||
require the caller's role be in that set.
|
||||
"""
|
||||
|
||||
allowed = frozenset(allowed_roles)
|
||||
|
||||
def deco(fn: F) -> F:
|
||||
@wraps(fn)
|
||||
def wrapper(*args: object, **kwargs: object) -> object:
|
||||
ctx = try_get_auth_ctx()
|
||||
if ctx is None or ctx.account_id is None:
|
||||
raise RuntimeError(
|
||||
"require_workspace_role called without account-bearer context; "
|
||||
"stack validate_bearer + accept_subjects(SubjectType.ACCOUNT) above it"
|
||||
)
|
||||
|
||||
workspace_id = kwargs.get("workspace_id")
|
||||
if not workspace_id:
|
||||
raise RuntimeError("require_workspace_role expects a 'workspace_id' route parameter")
|
||||
|
||||
role = TenantService.get_account_role_in_tenant(db.session, str(ctx.account_id), str(workspace_id))
|
||||
|
||||
if role is None:
|
||||
raise NotFound("workspace not found")
|
||||
|
||||
if allowed and role not in allowed:
|
||||
raise Forbidden("insufficient workspace role")
|
||||
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return deco
|
||||
@ -1,20 +1,41 @@
|
||||
"""User-scoped workspace reads under /openapi/v1/workspaces. Bearer-authed
|
||||
counterparts to the cookie-authed /console/api/workspaces endpoints.
|
||||
"""User-scoped workspace reads and member management under /openapi/v1/workspaces.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Member-management endpoints are gated by both `accept_subjects` (SSO out)
|
||||
and `require_workspace_role` (membership / role lookup against the path's
|
||||
``workspace_id``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import starmap
|
||||
from urllib import parse
|
||||
|
||||
from flask import jsonify, make_response, request
|
||||
from flask_restx import Resource
|
||||
from werkzeug.exceptions import NotFound
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.schema import query_params_from_model
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._models import WorkspaceDetailResponse, WorkspaceListResponse, WorkspaceSummaryResponse
|
||||
from controllers.openapi._models import (
|
||||
MemberActionResponse,
|
||||
MemberInvitePayload,
|
||||
MemberInviteResponse,
|
||||
MemberListQuery,
|
||||
MemberListResponse,
|
||||
MemberResponse,
|
||||
MemberRoleUpdatePayload,
|
||||
WorkspaceDetailResponse,
|
||||
WorkspaceListResponse,
|
||||
WorkspaceSummaryResponse,
|
||||
)
|
||||
from controllers.openapi.auth.role_gate import require_workspace_role
|
||||
from controllers.openapi.auth.surface_gate import accept_subjects
|
||||
from extensions.ext_database import db
|
||||
from libs.oauth_bearer import (
|
||||
@ -23,8 +44,105 @@ from libs.oauth_bearer import (
|
||||
get_auth_ctx,
|
||||
validate_bearer,
|
||||
)
|
||||
from models import Tenant, TenantAccountJoin
|
||||
from services.account_service import TenantService
|
||||
from models import Account, Tenant, TenantAccountJoin
|
||||
from models.account import TenantAccountRole, TenantStatus
|
||||
from services.account_service import AccountService, RegisterService, TenantService
|
||||
from services.errors.account import (
|
||||
AccountAlreadyInTenantError,
|
||||
AccountNotLinkTenantError,
|
||||
AccountRegisterError,
|
||||
CannotOperateSelfError,
|
||||
MemberNotInTenantError,
|
||||
NoPermissionError,
|
||||
RoleAlreadyAssignedError,
|
||||
)
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
def _validate_body[M: BaseModel](model: type[M]) -> M:
|
||||
"""Validate JSON body against ``model``. Validation errors → HTTP 400.
|
||||
|
||||
The workspace spec is explicit that bad email / unknown role payloads
|
||||
are 400, not Pydantic's default 422 — handle uniformly here.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
return model.model_validate(body)
|
||||
except ValidationError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
|
||||
|
||||
def _member_response(account: Account) -> MemberResponse:
|
||||
return MemberResponse(
|
||||
id=str(account.id),
|
||||
name=account.name,
|
||||
email=account.email,
|
||||
role=account.role.value if account.role else "",
|
||||
status=account.status.value if account.status else "",
|
||||
avatar=account.avatar,
|
||||
)
|
||||
|
||||
|
||||
def _load_tenant(workspace_id: str) -> Tenant:
|
||||
tenant = TenantService.get_tenant_by_id(db.session, workspace_id)
|
||||
if tenant is None or tenant.status != TenantStatus.NORMAL:
|
||||
raise NotFound("workspace not found")
|
||||
return tenant
|
||||
|
||||
|
||||
def _load_account(account_id: object) -> Account:
|
||||
"""Load the caller's Account. Missing == auth wiring bug, not user error."""
|
||||
account = AccountService.get_account_by_id(db.session, str(account_id)) if account_id else None
|
||||
if account is None:
|
||||
raise RuntimeError("authenticated account_id has no Account row")
|
||||
return account
|
||||
|
||||
|
||||
def _quota_error(*, code: str, message: str, hint: str) -> Forbidden:
|
||||
"""Build a 403 with envelope ``{code, message, hint}``.
|
||||
|
||||
CLI ``error-mapper`` reads ``message`` and ``hint`` off the wire body
|
||||
verbatim — the structured envelope lets it surface remediation guidance
|
||||
(e.g. "upgrade your plan") without the CLI needing to know edition
|
||||
semantics.
|
||||
"""
|
||||
err = Forbidden(message)
|
||||
err.response = make_response(
|
||||
jsonify({"code": code, "message": message, "hint": hint}),
|
||||
403,
|
||||
)
|
||||
return err
|
||||
|
||||
|
||||
def _check_member_invite_quota(tenant_id: str) -> None:
|
||||
"""Edition-aware member-count gate for invite.
|
||||
|
||||
Both branches self-disable on CE because ``FeatureService.get_features``
|
||||
leaves ``billing.enabled`` and ``workspace_members.enabled`` False by
|
||||
default; SaaS billing API and EE license activation are what flip them on.
|
||||
|
||||
Mirrors the two checks the console invite path performs (decorator at
|
||||
``console/wraps.py:106`` for billing + inline at
|
||||
``console/workspace/members.py:130`` for license).
|
||||
"""
|
||||
features = FeatureService.get_features(tenant_id)
|
||||
|
||||
if features.billing.enabled:
|
||||
members = features.members
|
||||
if 0 < members.limit <= members.size:
|
||||
raise _quota_error(
|
||||
code="members.limit_exceeded",
|
||||
message="Subscription member limit reached.",
|
||||
hint="Upgrade your plan to invite more members or remove an existing member first.",
|
||||
)
|
||||
|
||||
if features.workspace_members.enabled:
|
||||
if not features.workspace_members.is_available(1):
|
||||
raise _quota_error(
|
||||
code="workspace_members.license_exceeded",
|
||||
message="Workspace member license capacity reached.",
|
||||
hint="Contact your workspace administrator to expand the license seat count.",
|
||||
)
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces")
|
||||
@ -57,6 +175,187 @@ class WorkspaceByIdApi(Resource):
|
||||
return _workspace_detail(tenant, membership).model_dump(mode="json"), 200
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/switch")
|
||||
class WorkspaceSwitchApi(Resource):
|
||||
"""Server-side switch — equivalent to the console's POST /workspaces/switch.
|
||||
|
||||
CLI `difyctl use workspace <id>` calls this; it does NOT mutate
|
||||
``hosts.yml`` on its own. Failure here must abort the local write so
|
||||
that ``hosts.yml`` never diverges from the server's ``current`` state.
|
||||
"""
|
||||
|
||||
@openapi_ns.response(200, "Workspace detail", openapi_ns.models[WorkspaceDetailResponse.__name__])
|
||||
@validate_bearer(accept=ACCEPT_USER_ANY)
|
||||
@accept_subjects(SubjectType.ACCOUNT)
|
||||
@require_workspace_role()
|
||||
def post(self, workspace_id: str):
|
||||
ctx = get_auth_ctx()
|
||||
account = _load_account(ctx.account_id)
|
||||
|
||||
try:
|
||||
TenantService.switch_tenant(account, workspace_id)
|
||||
except AccountNotLinkTenantError:
|
||||
# Membership existed at gate time but Tenant.status != NORMAL or
|
||||
# the row was just removed — treat as not-found.
|
||||
raise NotFound("workspace not found")
|
||||
|
||||
row = TenantService.find_workspace_for_account(db.session, str(ctx.account_id), workspace_id)
|
||||
if row is None:
|
||||
raise NotFound("workspace not found")
|
||||
tenant, membership = row
|
||||
return _workspace_detail(tenant, membership).model_dump(mode="json"), 200
|
||||
|
||||
|
||||
@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).
|
||||
"""
|
||||
|
||||
@openapi_ns.doc(params=query_params_from_model(MemberListQuery))
|
||||
@openapi_ns.response(200, "Member list", openapi_ns.models[MemberListResponse.__name__])
|
||||
@validate_bearer(accept=ACCEPT_USER_ANY)
|
||||
@accept_subjects(SubjectType.ACCOUNT)
|
||||
@require_workspace_role()
|
||||
def get(self, workspace_id: str):
|
||||
try:
|
||||
query = MemberListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
except ValidationError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
|
||||
tenant = _load_tenant(workspace_id)
|
||||
# Members per workspace are bounded by SaaS plan caps (≤50) or EE
|
||||
# license seats (low thousands worst-case), so we materialize and
|
||||
# slice in-memory rather than push pagination into the service —
|
||||
# matches how the rest of the service exposes member lists.
|
||||
members = TenantService.get_tenant_members(tenant)
|
||||
total = len(members)
|
||||
start = (query.page - 1) * query.limit
|
||||
page_items = members[start : start + query.limit]
|
||||
return MemberListResponse(
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
total=total,
|
||||
has_more=query.page * query.limit < total,
|
||||
data=[_member_response(m) for m in page_items],
|
||||
).model_dump(mode="json"), 200
|
||||
|
||||
@openapi_ns.expect(openapi_ns.models[MemberInvitePayload.__name__])
|
||||
@openapi_ns.response(201, "Member invited", openapi_ns.models[MemberInviteResponse.__name__])
|
||||
@validate_bearer(accept=ACCEPT_USER_ANY)
|
||||
@accept_subjects(SubjectType.ACCOUNT)
|
||||
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
|
||||
def post(self, workspace_id: str):
|
||||
payload = _validate_body(MemberInvitePayload)
|
||||
ctx = get_auth_ctx()
|
||||
inviter = _load_account(ctx.account_id)
|
||||
tenant = _load_tenant(workspace_id)
|
||||
|
||||
_check_member_invite_quota(str(tenant.id))
|
||||
|
||||
try:
|
||||
token = RegisterService.invite_new_member(
|
||||
tenant=tenant,
|
||||
email=payload.email,
|
||||
language=None,
|
||||
role=payload.role,
|
||||
inviter=inviter,
|
||||
)
|
||||
except AccountAlreadyInTenantError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
except NoPermissionError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
except AccountRegisterError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
|
||||
normalized_email = payload.email.lower()
|
||||
member = AccountService.get_account_by_email_with_case_fallback(normalized_email)
|
||||
if member is None:
|
||||
# invite_new_member just created or fetched this account.
|
||||
raise RuntimeError("invited member missing from DB after invite")
|
||||
|
||||
encoded_email = parse.quote(normalized_email)
|
||||
invite_url = f"{dify_config.CONSOLE_WEB_URL}/activate?email={encoded_email}&token={token}"
|
||||
return MemberInviteResponse(
|
||||
email=normalized_email,
|
||||
role=payload.role,
|
||||
member_id=str(member.id),
|
||||
invite_url=invite_url,
|
||||
tenant_id=str(tenant.id),
|
||||
).model_dump(mode="json"), 201
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/members/<string:member_id>")
|
||||
class WorkspaceMemberApi(Resource):
|
||||
"""Remove a member.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
@openapi_ns.response(200, "Member removed", openapi_ns.models[MemberActionResponse.__name__])
|
||||
@validate_bearer(accept=ACCEPT_USER_ANY)
|
||||
@accept_subjects(SubjectType.ACCOUNT)
|
||||
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
|
||||
def delete(self, workspace_id: str, member_id: str):
|
||||
ctx = get_auth_ctx()
|
||||
operator = _load_account(ctx.account_id)
|
||||
tenant = _load_tenant(workspace_id)
|
||||
member = AccountService.get_account_by_id(db.session, member_id)
|
||||
if member is None:
|
||||
raise NotFound("member not found")
|
||||
|
||||
try:
|
||||
TenantService.remove_member_from_tenant(tenant, member, operator)
|
||||
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().model_dump(mode="json"), 200
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/members/<string:member_id>/role")
|
||||
class WorkspaceMemberRoleApi(Resource):
|
||||
"""Change a member's role.
|
||||
|
||||
Owner cannot be assigned here (closed enum). Admin cannot demote the
|
||||
standing owner (service NoPermissionError → 400, per spec).
|
||||
"""
|
||||
|
||||
@openapi_ns.expect(openapi_ns.models[MemberRoleUpdatePayload.__name__])
|
||||
@openapi_ns.response(200, "Role updated", openapi_ns.models[MemberActionResponse.__name__])
|
||||
@validate_bearer(accept=ACCEPT_USER_ANY)
|
||||
@accept_subjects(SubjectType.ACCOUNT)
|
||||
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
|
||||
def put(self, workspace_id: str, member_id: str):
|
||||
payload = _validate_body(MemberRoleUpdatePayload)
|
||||
ctx = get_auth_ctx()
|
||||
operator = _load_account(ctx.account_id)
|
||||
tenant = _load_tenant(workspace_id)
|
||||
member = AccountService.get_account_by_id(db.session, member_id)
|
||||
if member is None:
|
||||
raise NotFound("member not found")
|
||||
|
||||
try:
|
||||
TenantService.update_member_role(tenant, member, payload.role, operator)
|
||||
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))
|
||||
|
||||
return MemberActionResponse().model_dump(mode="json"), 200
|
||||
|
||||
|
||||
def _workspace_summary(tenant: Tenant, membership: TenantAccountJoin) -> WorkspaceSummaryResponse:
|
||||
return WorkspaceSummaryResponse(
|
||||
id=str(tenant.id),
|
||||
|
||||
@ -323,6 +323,85 @@ Upload a file to use as an input variable when running the app
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) |
|
||||
|
||||
### /workspaces/{workspace_id}/members
|
||||
|
||||
#### GET
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| workspace_id | path | | Yes | string |
|
||||
| limit | query | | No | integer |
|
||||
| page | query | | No | integer |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Member list | [MemberListResponse](#memberlistresponse) |
|
||||
|
||||
#### POST
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| workspace_id | path | | Yes | string |
|
||||
| payload | body | | Yes | [MemberInvitePayload](#memberinvitepayload) |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | Member invited | [MemberInviteResponse](#memberinviteresponse) |
|
||||
|
||||
### /workspaces/{workspace_id}/members/{member_id}
|
||||
|
||||
#### DELETE
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| member_id | path | | Yes | string |
|
||||
| workspace_id | path | | Yes | string |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Member removed | [MemberActionResponse](#memberactionresponse) |
|
||||
|
||||
### /workspaces/{workspace_id}/members/{member_id}/role
|
||||
|
||||
#### PUT
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| member_id | path | | Yes | string |
|
||||
| workspace_id | path | | Yes | string |
|
||||
| payload | body | | Yes | [MemberRoleUpdatePayload](#memberroleupdatepayload) |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Role updated | [MemberActionResponse](#memberactionresponse) |
|
||||
|
||||
### /workspaces/{workspace_id}/switch
|
||||
|
||||
#### POST
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| workspace_id | path | | Yes | string |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) |
|
||||
|
||||
---
|
||||
### Models
|
||||
|
||||
@ -526,6 +605,66 @@ mode is a closed enum.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| JsonValue | | | |
|
||||
|
||||
#### MemberActionResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| result | string | | No |
|
||||
|
||||
#### MemberInvitePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| email | string | | Yes |
|
||||
| role | string | *Enum:* `"admin"`, `"normal"` | Yes |
|
||||
|
||||
#### MemberInviteResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| email | string | | Yes |
|
||||
| invite_url | string | | Yes |
|
||||
| member_id | string | | Yes |
|
||||
| result | string | | No |
|
||||
| role | string | | Yes |
|
||||
| tenant_id | string | | Yes |
|
||||
|
||||
#### MemberListQuery
|
||||
|
||||
Strict (extra='forbid').
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| limit | integer | | No |
|
||||
| page | integer | | No |
|
||||
|
||||
#### MemberListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [MemberResponse](#memberresponse) ] | | Yes |
|
||||
| has_more | boolean | | Yes |
|
||||
| limit | integer | | Yes |
|
||||
| page | integer | | Yes |
|
||||
| total | integer | | Yes |
|
||||
|
||||
#### MemberResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| avatar | string | | No |
|
||||
| email | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| role | string | | Yes |
|
||||
| status | string | | Yes |
|
||||
|
||||
#### MemberRoleUpdatePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| role | string | *Enum:* `"admin"`, `"normal"` | Yes |
|
||||
|
||||
#### MessageMetadata
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -1287,6 +1287,34 @@ class TenantService:
|
||||
).scalar_one_or_none()
|
||||
return row is not None
|
||||
|
||||
@staticmethod
|
||||
def get_account_role_in_tenant(
|
||||
session: Session | scoped_session,
|
||||
account_id: uuid.UUID | str | None,
|
||||
tenant_id: str,
|
||||
) -> TenantAccountRole | None:
|
||||
"""Return the caller's role in ``tenant_id``, or ``None`` if not a member.
|
||||
|
||||
Backs ``controllers.openapi.auth.role_gate.require_workspace_role``:
|
||||
the gate maps ``None`` to 404 (non-member — no cross-tenant ID leak)
|
||||
and an out-of-set role to 403, so it never touches the ORM itself.
|
||||
|
||||
``None``/empty ``account_id`` short-circuits to ``None`` so SSO
|
||||
bearers (no account) collapse to the non-member path. Mirrors the
|
||||
session-injection style of :meth:`account_belongs_to_tenant` rather
|
||||
than :meth:`get_user_role`, which loads full ``Account``/``Tenant``
|
||||
objects against the Flask-scoped ``db.session``.
|
||||
"""
|
||||
if not account_id:
|
||||
return None
|
||||
role = session.execute(
|
||||
select(TenantAccountJoin.role).where(
|
||||
TenantAccountJoin.tenant_id == tenant_id,
|
||||
TenantAccountJoin.account_id == account_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return TenantAccountRole(role) if role is not None else None
|
||||
|
||||
@staticmethod
|
||||
def get_tenant_by_id(session: Session | scoped_session, tenant_id: str) -> Tenant | None:
|
||||
"""Plain ``session.get(Tenant, tenant_id)`` — no status filter.
|
||||
|
||||
330
api/tests/unit_tests/controllers/openapi/auth/test_role_gate.py
Normal file
330
api/tests/unit_tests/controllers/openapi/auth/test_role_gate.py
Normal file
@ -0,0 +1,330 @@
|
||||
"""Role-gate tests.
|
||||
|
||||
The decorator wraps `validate_bearer` + `accept_subjects` and must:
|
||||
- 404 when caller is not a member of ``workspace_id`` (parity with
|
||||
`GET /openapi/v1/workspaces/<id>`; prevents tenant-id existence leak)
|
||||
- 403 when caller IS a member but their role is not in the allowed set
|
||||
- pass through when role matches (or when no role restriction given)
|
||||
- raise RuntimeError on missing auth context / account_id / workspace_id —
|
||||
those are wiring bugs, not user-driven failures
|
||||
|
||||
Identity is read from the openapi auth ContextVar — the slot
|
||||
`validate_bearer` publishes — so these tests seed it via `_seed`
|
||||
(``set_auth_ctx``), NOT ``flask.g``. `test_seeding_only_flask_g_*`
|
||||
locks in that ``g`` is *not* a valid identity source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from controllers.openapi.auth.role_gate import require_workspace_role
|
||||
from libs.oauth_bearer import AuthContext, Scope, SubjectType, reset_auth_ctx, set_auth_ctx
|
||||
from models.account import TenantAccountRole
|
||||
|
||||
# Tokens from `_seed`'s `set_auth_ctx` calls, drained after each test so a
|
||||
# published identity can't leak into the next (the ContextVar is module-global
|
||||
# and worker threads are reused). Seed via `_seed(...)`, never `flask.g`.
|
||||
_seed_tokens: list = []
|
||||
|
||||
|
||||
def _seed(ctx: AuthContext) -> None:
|
||||
_seed_tokens.append(set_auth_ctx(ctx))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_auth_ctx():
|
||||
yield
|
||||
while _seed_tokens:
|
||||
reset_auth_ctx(_seed_tokens.pop())
|
||||
|
||||
|
||||
def _account_ctx(account_id: uuid.UUID | None = None) -> AuthContext:
|
||||
return AuthContext(
|
||||
subject_type=SubjectType.ACCOUNT,
|
||||
subject_email="user@example.com",
|
||||
subject_issuer="dify:account",
|
||||
account_id=account_id or uuid.uuid4(),
|
||||
client_id="difyctl",
|
||||
scopes=frozenset({Scope.FULL}),
|
||||
token_id=uuid.uuid4(),
|
||||
source="oauth_account",
|
||||
expires_at=datetime.now(UTC),
|
||||
token_hash="h1",
|
||||
verified_tenants={},
|
||||
)
|
||||
|
||||
|
||||
def _sso_ctx() -> AuthContext:
|
||||
return AuthContext(
|
||||
subject_type=SubjectType.EXTERNAL_SSO,
|
||||
subject_email="sso@partner.com",
|
||||
subject_issuer="https://idp.partner.com",
|
||||
account_id=None,
|
||||
client_id="difyctl",
|
||||
scopes=frozenset({Scope.APPS_RUN}),
|
||||
token_id=uuid.uuid4(),
|
||||
source="oauth_external_sso",
|
||||
expires_at=datetime.now(UTC),
|
||||
token_hash="h2",
|
||||
verified_tenants={},
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _stub_role(role: TenantAccountRole | None):
|
||||
"""Stub the service-layer membership lookup the gate delegates to.
|
||||
|
||||
The gate no longer issues SQL itself — it calls
|
||||
``TenantService.get_account_role_in_tenant`` and acts purely on the
|
||||
returned role (``None`` → non-member). These tests pin that behaviour;
|
||||
the query itself is covered in ``TestTenantService``.
|
||||
"""
|
||||
with patch(
|
||||
"controllers.openapi.auth.role_gate.TenantService.get_account_role_in_tenant",
|
||||
return_value=role,
|
||||
) as mocked:
|
||||
yield mocked
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-member → 404
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_non_member_gets_404():
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role()
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
|
||||
_seed(_account_ctx())
|
||||
with _stub_role(None):
|
||||
with pytest.raises(NotFound):
|
||||
view(workspace_id=workspace_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Member with insufficient role → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normal_member_blocked_when_admin_required():
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/members"):
|
||||
_seed(_account_ctx())
|
||||
with _stub_role(TenantAccountRole.NORMAL):
|
||||
with pytest.raises(Forbidden):
|
||||
view(workspace_id=workspace_id)
|
||||
|
||||
|
||||
def test_editor_blocked_when_admin_required():
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/members"):
|
||||
_seed(_account_ctx())
|
||||
with _stub_role(TenantAccountRole.EDITOR):
|
||||
with pytest.raises(Forbidden):
|
||||
view(workspace_id=workspace_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Member with allowed role → pass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_passes_when_admin_required():
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/members"):
|
||||
_seed(_account_ctx())
|
||||
with _stub_role(TenantAccountRole.ADMIN):
|
||||
assert view(workspace_id=workspace_id) == "ok"
|
||||
|
||||
|
||||
def test_owner_passes_when_admin_required():
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/members"):
|
||||
_seed(_account_ctx())
|
||||
with _stub_role(TenantAccountRole.OWNER):
|
||||
assert view(workspace_id=workspace_id) == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Membership-only (no role restriction)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_membership_only_passes_for_any_role():
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role()
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
for role in (
|
||||
TenantAccountRole.OWNER,
|
||||
TenantAccountRole.ADMIN,
|
||||
TenantAccountRole.EDITOR,
|
||||
TenantAccountRole.NORMAL,
|
||||
TenantAccountRole.DATASET_OPERATOR,
|
||||
):
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
|
||||
_seed(_account_ctx())
|
||||
with _stub_role(role):
|
||||
assert view(workspace_id=workspace_id) == "ok"
|
||||
|
||||
|
||||
def test_membership_only_still_404s_non_member():
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role()
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
|
||||
_seed(_account_ctx())
|
||||
with _stub_role(None):
|
||||
with pytest.raises(NotFound):
|
||||
view(workspace_id=workspace_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lookup is scoped to the caller's account_id and the URL workspace_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_lookup_is_scoped_to_caller_and_workspace():
|
||||
"""The decorator must delegate the lookup keyed on
|
||||
`(caller's account_id, URL workspace_id)` — otherwise a member of
|
||||
workspace A could quietly hit endpoints for workspace B. Assert the
|
||||
exact arguments handed to the service; the SQL those arguments compile
|
||||
to is pinned in ``TestTenantService.test_get_account_role_in_tenant_*``.
|
||||
"""
|
||||
|
||||
app = Flask(__name__)
|
||||
account_id = uuid.uuid4()
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role()
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
|
||||
_seed(_account_ctx(account_id=account_id))
|
||||
with _stub_role(TenantAccountRole.NORMAL) as mocked:
|
||||
view(workspace_id=workspace_id)
|
||||
|
||||
_session, passed_account_id, passed_workspace_id = mocked.call_args.args
|
||||
assert passed_account_id == str(account_id)
|
||||
assert passed_workspace_id == workspace_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wiring bugs surface as RuntimeError (loud), not 403 (silent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_missing_auth_ctx_is_runtime_error():
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role()
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
|
||||
with pytest.raises(RuntimeError):
|
||||
view(workspace_id=workspace_id)
|
||||
|
||||
|
||||
def test_seeding_only_flask_g_does_not_satisfy_gate():
|
||||
"""Regression — pins the identity source to the ContextVar, not ``flask.g``.
|
||||
|
||||
Production fills the ContextVar (``validate_bearer`` → ``set_auth_ctx``)
|
||||
and never touches ``g.auth_ctx``. An earlier revision of this gate read
|
||||
``g.auth_ctx``, so every real request raised RuntimeError → 500 while the
|
||||
suite stayed green (it seeded ``g`` directly). Here we seed ONLY ``g`` and
|
||||
leave the ContextVar empty: the gate must still raise, proving it does not
|
||||
accept ``g`` as an identity source. Reading ``g`` again would let the
|
||||
membership lookup run (stubbed to succeed) and this would fail.
|
||||
"""
|
||||
from flask import g
|
||||
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role()
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
|
||||
g.auth_ctx = _account_ctx() # the wrong slot — must be ignored
|
||||
with _stub_role(TenantAccountRole.OWNER):
|
||||
with pytest.raises(RuntimeError):
|
||||
view(workspace_id=workspace_id)
|
||||
|
||||
|
||||
def test_sso_caller_is_runtime_error():
|
||||
"""External SSO context has account_id=None — the caller stacked the
|
||||
role gate without `accept_subjects(SubjectType.ACCOUNT)`. That's a
|
||||
wiring bug, surface it as RuntimeError rather than 404 the SSO user."""
|
||||
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role()
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
|
||||
_seed(_sso_ctx())
|
||||
with pytest.raises(RuntimeError):
|
||||
view(workspace_id=workspace_id)
|
||||
|
||||
|
||||
def test_missing_workspace_id_kwarg_is_runtime_error():
|
||||
app = Flask(__name__)
|
||||
|
||||
@require_workspace_role()
|
||||
def view() -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context("/openapi/v1/foo"):
|
||||
_seed(_account_ctx())
|
||||
with pytest.raises(RuntimeError):
|
||||
view()
|
||||
@ -0,0 +1,918 @@
|
||||
"""Member endpoints under /openapi/v1/workspaces/<id>/...
|
||||
|
||||
Coverage:
|
||||
- Route registration (5 endpoints across 4 URL patterns)
|
||||
- Body validation lands at 400 (per spec — not Pydantic's default 422)
|
||||
- Domain exception → HTTP code mapping is preserved with the service's
|
||||
original message (so CLI users see what the console user sees)
|
||||
- Response shape matches the Pydantic models
|
||||
|
||||
Auth-pipeline plumbing is bypassed via the `bypass_pipeline` fixture from
|
||||
conftest.py; the bearer identity is seeded into the openapi auth ContextVar
|
||||
via `_seed` (the slot `validate_bearer` publishes), and the role gate's DB
|
||||
lookup is mocked. Tests that exercise endpoint *bodies* skip the decorators
|
||||
via ``__wrapped__`` since those layers are covered in `auth/test_role_gate.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask.views import MethodView
|
||||
from pydantic import ValidationError
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
|
||||
from controllers.openapi import bp as openapi_bp
|
||||
from controllers.openapi._models import MemberInvitePayload, MemberRoleUpdatePayload
|
||||
from controllers.openapi.workspaces import (
|
||||
WorkspaceMemberApi,
|
||||
WorkspaceMemberRoleApi,
|
||||
WorkspaceMembersApi,
|
||||
WorkspaceSwitchApi,
|
||||
)
|
||||
from libs.oauth_bearer import AuthContext, Scope, SubjectType, reset_auth_ctx, set_auth_ctx
|
||||
from models.account import AccountStatus, TenantAccountRole
|
||||
from services.errors.account import (
|
||||
AccountAlreadyInTenantError,
|
||||
AccountNotLinkTenantError,
|
||||
AccountRegisterError,
|
||||
CannotOperateSelfError,
|
||||
MemberNotInTenantError,
|
||||
NoPermissionError,
|
||||
RoleAlreadyAssignedError,
|
||||
)
|
||||
|
||||
if not hasattr(builtins, "MethodView"):
|
||||
builtins.MethodView = MethodView # type: ignore[attr-defined]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Tokens from `_seed`'s `set_auth_ctx` calls, drained after each test so a
|
||||
# published identity can't leak into the next (the ContextVar is module-global
|
||||
# and worker threads are reused). Seed via `_seed(...)`, never `flask.g` —
|
||||
# production fills the ContextVar, nothing fills `g.auth_ctx`.
|
||||
_seed_tokens: list = []
|
||||
|
||||
|
||||
def _seed(ctx: AuthContext) -> None:
|
||||
_seed_tokens.append(set_auth_ctx(ctx))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_auth_ctx():
|
||||
yield
|
||||
while _seed_tokens:
|
||||
reset_auth_ctx(_seed_tokens.pop())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def openapi_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(openapi_bp)
|
||||
return app
|
||||
|
||||
|
||||
def _rule(app: Flask, path: str):
|
||||
return next(r for r in app.url_map.iter_rules() if r.rule == path)
|
||||
|
||||
|
||||
def _auth_ctx(account_id: uuid.UUID | None = None) -> AuthContext:
|
||||
return AuthContext(
|
||||
subject_type=SubjectType.ACCOUNT,
|
||||
subject_email="caller@example.com",
|
||||
subject_issuer="dify:account",
|
||||
account_id=account_id or uuid.uuid4(),
|
||||
client_id="difyctl",
|
||||
scopes=frozenset({Scope.FULL}),
|
||||
token_id=uuid.uuid4(),
|
||||
source="oauth_account",
|
||||
expires_at=datetime.now(UTC),
|
||||
token_hash="h",
|
||||
verified_tenants={},
|
||||
)
|
||||
|
||||
|
||||
def _account(account_id: str = "acct-1", email: str = "u@example.com") -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=account_id,
|
||||
name="User",
|
||||
email=email,
|
||||
status=AccountStatus.ACTIVE,
|
||||
avatar=None,
|
||||
)
|
||||
|
||||
|
||||
def _tenant(tenant_id: str = "ws-1") -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=tenant_id,
|
||||
name="WS",
|
||||
status="normal",
|
||||
created_at=datetime(2026, 5, 18, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def _tenant_service(**overrides) -> SimpleNamespace:
|
||||
"""TenantService double for the workspaces module.
|
||||
|
||||
Read getters (`get_tenant_by_id`, `find_workspace_for_account`) delegate
|
||||
to the session they're handed, so tests keep driving entity loads through
|
||||
``mock_db.session.get`` / ``.execute`` and their existing side_effect
|
||||
ordering — the SQL those methods run is covered in test_account_service.py.
|
||||
Domain mutators default to no-op Mocks; override per test as needed.
|
||||
"""
|
||||
methods: dict = {
|
||||
"switch_tenant": Mock(),
|
||||
"get_tenant_members": Mock(return_value=[]),
|
||||
"remove_member_from_tenant": Mock(),
|
||||
"update_member_role": Mock(),
|
||||
"get_tenant_by_id": lambda session, tenant_id: session.get(None, tenant_id),
|
||||
"find_workspace_for_account": lambda session, account_id, workspace_id: session.execute(None).first(),
|
||||
}
|
||||
methods.update(overrides)
|
||||
return SimpleNamespace(**methods)
|
||||
|
||||
|
||||
def _account_service(**overrides) -> SimpleNamespace:
|
||||
"""AccountService double; ``get_account_by_id`` delegates to the injected
|
||||
session (see :func:`_tenant_service`)."""
|
||||
methods: dict = {
|
||||
"get_account_by_id": lambda session, account_id: session.get(None, account_id),
|
||||
}
|
||||
methods.update(overrides)
|
||||
return SimpleNamespace(**methods)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_switch_route_registered(openapi_app: Flask):
|
||||
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/switch")
|
||||
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceSwitchApi
|
||||
assert "POST" in rule.methods
|
||||
|
||||
|
||||
def test_members_route_registered(openapi_app: Flask):
|
||||
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/members")
|
||||
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceMembersApi
|
||||
assert "GET" in rule.methods
|
||||
assert "POST" in rule.methods
|
||||
|
||||
|
||||
def test_member_by_id_route_registered(openapi_app: Flask):
|
||||
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/members/<string:member_id>")
|
||||
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceMemberApi
|
||||
assert "DELETE" in rule.methods
|
||||
|
||||
|
||||
def test_member_role_route_registered(openapi_app: Flask):
|
||||
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/members/<string:member_id>/role")
|
||||
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceMemberRoleApi
|
||||
assert "PUT" in rule.methods
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payload validation lands at 400
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_invite_payload_rejects_unknown_role():
|
||||
with pytest.raises(ValidationError):
|
||||
MemberInvitePayload.model_validate({"email": "u@example.com", "role": "owner"})
|
||||
|
||||
|
||||
def test_invite_payload_rejects_bad_email():
|
||||
with pytest.raises(ValidationError):
|
||||
MemberInvitePayload.model_validate({"email": "not-an-email", "role": "normal"})
|
||||
|
||||
|
||||
def test_invite_payload_rejects_extra_field():
|
||||
with pytest.raises(ValidationError):
|
||||
MemberInvitePayload.model_validate({"email": "u@example.com", "role": "normal", "extra": "x"})
|
||||
|
||||
|
||||
def test_role_payload_rejects_owner():
|
||||
with pytest.raises(ValidationError):
|
||||
MemberRoleUpdatePayload.model_validate({"role": "owner"})
|
||||
|
||||
|
||||
def test_role_payload_rejects_extra_field():
|
||||
with pytest.raises(ValidationError):
|
||||
MemberRoleUpdatePayload.model_validate({"role": "normal", "extra": "x"})
|
||||
|
||||
|
||||
def test_validate_body_helper_maps_validation_error_to_400(app, monkeypatch):
|
||||
"""`_validate_body` is the centralized 400-mapper for invalid request bodies."""
|
||||
from controllers.openapi.workspaces import _validate_body
|
||||
|
||||
with app.test_request_context(
|
||||
"/openapi/v1/workspaces/ws-1/members",
|
||||
method="POST",
|
||||
data=json.dumps({"email": "u@example.com", "role": "owner"}),
|
||||
content_type="application/json",
|
||||
):
|
||||
with pytest.raises(BadRequest):
|
||||
_validate_body(MemberInvitePayload)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Switch endpoint behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_switch_returns_workspace_detail_with_current_true(app, bypass_pipeline, monkeypatch):
|
||||
"""Happy path: switch service is called, then the workspace+membership
|
||||
row is re-queried so the returned `current` reflects post-commit state.
|
||||
"""
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceSwitchApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.return_value = _account(account_id=str(acct_id))
|
||||
membership = SimpleNamespace(role=TenantAccountRole.OWNER, current=True)
|
||||
mock_db.session.execute.return_value.first.return_value = (_tenant(ws_id), membership)
|
||||
|
||||
switch_mock = Mock()
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"TenantService",
|
||||
_tenant_service(switch_tenant=switch_mock),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.post.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
|
||||
|
||||
assert status == 200
|
||||
assert body["id"] == ws_id
|
||||
assert body["current"] is True
|
||||
assert switch_mock.called
|
||||
|
||||
|
||||
def test_switch_404s_when_service_raises_account_not_link_tenant(app, bypass_pipeline, monkeypatch):
|
||||
"""If switch_tenant raises (e.g. Tenant.status != NORMAL), the body
|
||||
surfaces as NotFound, not 500."""
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceSwitchApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.return_value = _account(account_id=str(acct_id))
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"TenantService",
|
||||
_tenant_service(switch_tenant=Mock(side_effect=AccountNotLinkTenantError("…"))),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(NotFound):
|
||||
api.post.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Members list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_members_list_returns_normalized_rows(app, bypass_pipeline, monkeypatch):
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMembersApi()
|
||||
|
||||
member = SimpleNamespace(
|
||||
id="m-1",
|
||||
name="Mia",
|
||||
email="mia@example.com",
|
||||
status=AccountStatus.ACTIVE,
|
||||
avatar=None,
|
||||
role=TenantAccountRole.ADMIN,
|
||||
)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.return_value = _tenant(ws_id)
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"TenantService",
|
||||
_tenant_service(get_tenant_members=Mock(return_value=[member])),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.get.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
|
||||
|
||||
assert status == 200
|
||||
assert body["page"] == 1
|
||||
assert body["limit"] == 20
|
||||
assert body["total"] == 1
|
||||
assert body["has_more"] is False
|
||||
assert body["data"][0]["email"] == "mia@example.com"
|
||||
assert body["data"][0]["role"] == "admin"
|
||||
assert body["data"][0]["status"] == "active"
|
||||
|
||||
|
||||
def test_members_list_paginates_with_query_params(app, bypass_pipeline, monkeypatch):
|
||||
"""`?page=2&limit=2` slices service output and reports total/has_more."""
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMembersApi()
|
||||
|
||||
members = [
|
||||
SimpleNamespace(
|
||||
id=f"m-{i}",
|
||||
name=f"User {i}",
|
||||
email=f"u{i}@example.com",
|
||||
status=AccountStatus.ACTIVE,
|
||||
avatar=None,
|
||||
role=TenantAccountRole.NORMAL,
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.return_value = _tenant(ws_id)
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"TenantService",
|
||||
_tenant_service(get_tenant_members=Mock(return_value=members)),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members?page=2&limit=2"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.get.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
|
||||
|
||||
assert status == 200
|
||||
assert body["page"] == 2
|
||||
assert body["limit"] == 2
|
||||
assert body["total"] == 5
|
||||
assert body["has_more"] is True
|
||||
assert [d["id"] for d in body["data"]] == ["m-2", "m-3"]
|
||||
|
||||
|
||||
def test_members_list_rejects_unknown_query_param(app, bypass_pipeline, monkeypatch):
|
||||
"""Strict (`extra='forbid'`) — typos like `?pg=2` surface as 400."""
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMembersApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.return_value = _tenant(ws_id)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members?pg=2"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(BadRequest):
|
||||
api.get.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Invite endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_invite_happy_path_returns_invite_url_and_member_id(app, bypass_pipeline, monkeypatch):
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMembersApi()
|
||||
|
||||
invited = _account(account_id="new-1", email="new@example.com")
|
||||
|
||||
mock_db = MagicMock()
|
||||
# session.get is called twice: once for inviter Account, once for Tenant
|
||||
mock_db.session.get.side_effect = [_account(account_id=str(acct_id)), _tenant(ws_id)]
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"RegisterService",
|
||||
SimpleNamespace(invite_new_member=Mock(return_value="tok-123")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"AccountService",
|
||||
_account_service(get_account_by_email_with_case_fallback=Mock(return_value=invited)),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{ws_id}/members",
|
||||
method="POST",
|
||||
data=json.dumps({"email": "NEW@example.com", "role": "normal"}),
|
||||
content_type="application/json",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.post.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
|
||||
|
||||
assert status == 201
|
||||
assert body["result"] == "success"
|
||||
assert body["email"] == "new@example.com"
|
||||
assert body["role"] == "normal"
|
||||
assert body["member_id"] == "new-1"
|
||||
assert "token=tok-123" in body["invite_url"]
|
||||
assert "email=new%40example.com" in body["invite_url"]
|
||||
assert body["tenant_id"] == ws_id
|
||||
|
||||
|
||||
def _features(
|
||||
*,
|
||||
billing_enabled: bool = False,
|
||||
members_size: int = 0,
|
||||
members_limit: int = 0,
|
||||
workspace_members_enabled: bool = False,
|
||||
workspace_members_size: int = 0,
|
||||
workspace_members_limit: int = 0,
|
||||
) -> SimpleNamespace:
|
||||
"""Build a feature object matching the surface `_check_member_invite_quota`
|
||||
reads: `.billing.enabled`, `.members.{size,limit}`,
|
||||
`.workspace_members.{enabled, is_available(N)}`.
|
||||
|
||||
Defaults model CE (both flags off, both caps inert).
|
||||
"""
|
||||
|
||||
def _is_available(n: int) -> bool:
|
||||
return workspace_members_size + n <= workspace_members_limit
|
||||
|
||||
return SimpleNamespace(
|
||||
billing=SimpleNamespace(enabled=billing_enabled),
|
||||
members=SimpleNamespace(size=members_size, limit=members_limit),
|
||||
workspace_members=SimpleNamespace(
|
||||
enabled=workspace_members_enabled,
|
||||
size=workspace_members_size,
|
||||
limit=workspace_members_limit,
|
||||
is_available=_is_available,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _invite_request(app, ws_id: str, acct_id: uuid.UUID):
|
||||
return app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{ws_id}/members",
|
||||
method="POST",
|
||||
data=json.dumps({"email": "new@example.com", "role": "normal"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
def test_invite_blocked_by_saas_members_cap(app, bypass_pipeline, monkeypatch):
|
||||
"""SaaS billing plan member cap → 403 with `members.limit_exceeded`.
|
||||
|
||||
Verifies the envelope shape the CLI error-mapper relies on (code +
|
||||
message + hint on the wire body).
|
||||
"""
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMembersApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.side_effect = [_account(account_id=str(acct_id)), _tenant(ws_id)]
|
||||
|
||||
invite_mock = Mock()
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"RegisterService",
|
||||
SimpleNamespace(invite_new_member=invite_mock),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"FeatureService",
|
||||
SimpleNamespace(
|
||||
get_features=Mock(
|
||||
return_value=_features(billing_enabled=True, members_size=10, members_limit=10),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
with _invite_request(app, ws_id, acct_id):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
api.post.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
|
||||
|
||||
body = exc_info.value.response.json
|
||||
assert body["code"] == "members.limit_exceeded"
|
||||
assert "Subscription member limit" in body["message"]
|
||||
assert body["hint"]
|
||||
invite_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_invite_blocked_by_ee_workspace_members_license(app, bypass_pipeline, monkeypatch):
|
||||
"""EE License workspace_members cap → 403 with `workspace_members.license_exceeded`.
|
||||
|
||||
Note: billing.enabled is False (EE without SaaS billing); only the
|
||||
license cap fires.
|
||||
"""
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMembersApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.side_effect = [_account(account_id=str(acct_id)), _tenant(ws_id)]
|
||||
|
||||
invite_mock = Mock()
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"RegisterService",
|
||||
SimpleNamespace(invite_new_member=invite_mock),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"FeatureService",
|
||||
SimpleNamespace(
|
||||
get_features=Mock(
|
||||
return_value=_features(
|
||||
workspace_members_enabled=True,
|
||||
workspace_members_size=5,
|
||||
workspace_members_limit=5,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
with _invite_request(app, ws_id, acct_id):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
api.post.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
|
||||
|
||||
body = exc_info.value.response.json
|
||||
assert body["code"] == "workspace_members.license_exceeded"
|
||||
assert "license" in body["message"].lower()
|
||||
assert body["hint"]
|
||||
invite_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_invite_ce_passes_when_both_caps_disabled(app, bypass_pipeline, monkeypatch):
|
||||
"""CE deployment (no billing, no license) → quota gate is a no-op,
|
||||
invite proceeds normally."""
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMembersApi()
|
||||
|
||||
invited = _account(account_id="new-1", email="new@example.com")
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.side_effect = [_account(account_id=str(acct_id)), _tenant(ws_id)]
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"RegisterService",
|
||||
SimpleNamespace(invite_new_member=Mock(return_value="tok-ce")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"AccountService",
|
||||
_account_service(get_account_by_email_with_case_fallback=Mock(return_value=invited)),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"FeatureService",
|
||||
SimpleNamespace(get_features=Mock(return_value=_features())), # all defaults
|
||||
)
|
||||
|
||||
with _invite_request(app, ws_id, acct_id):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.post.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
|
||||
|
||||
assert status == 201
|
||||
assert body["email"] == "new@example.com"
|
||||
|
||||
|
||||
def test_invite_400_when_already_in_tenant(app, bypass_pipeline, monkeypatch):
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMembersApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.side_effect = [_account(account_id=str(acct_id)), _tenant(ws_id)]
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"RegisterService",
|
||||
SimpleNamespace(invite_new_member=Mock(side_effect=AccountAlreadyInTenantError("already in tenant"))),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{ws_id}/members",
|
||||
method="POST",
|
||||
data=json.dumps({"email": "u@example.com", "role": "normal"}),
|
||||
content_type="application/json",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(BadRequest):
|
||||
api.post.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delete member
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_member_happy_path(app, bypass_pipeline, monkeypatch):
|
||||
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMemberApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.side_effect = [
|
||||
_account(account_id=str(acct_id)), # operator
|
||||
_tenant(ws_id), # tenant
|
||||
_account(account_id=member_id), # target member
|
||||
]
|
||||
|
||||
remove_mock = Mock()
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"TenantService",
|
||||
_tenant_service(remove_member_from_tenant=remove_mock),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}",
|
||||
method="DELETE",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.delete.__wrapped__.__wrapped__.__wrapped__(
|
||||
api,
|
||||
workspace_id=ws_id,
|
||||
member_id=member_id,
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
assert body == {"result": "success"}
|
||||
assert remove_mock.called
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exc", "expected"),
|
||||
[
|
||||
(CannotOperateSelfError("cannot operate self"), BadRequest),
|
||||
(NoPermissionError("no permission"), BadRequest),
|
||||
(MemberNotInTenantError("not in tenant"), NotFound),
|
||||
],
|
||||
)
|
||||
def test_delete_member_exception_mapping(app, bypass_pipeline, monkeypatch, exc, expected):
|
||||
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMemberApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.side_effect = [
|
||||
_account(account_id=str(acct_id)),
|
||||
_tenant(ws_id),
|
||||
_account(account_id=member_id),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"TenantService",
|
||||
_tenant_service(remove_member_from_tenant=Mock(side_effect=exc)),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}",
|
||||
method="DELETE",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(expected):
|
||||
api.delete.__wrapped__.__wrapped__.__wrapped__(
|
||||
api,
|
||||
workspace_id=ws_id,
|
||||
member_id=member_id,
|
||||
)
|
||||
|
||||
|
||||
def test_delete_member_404_when_member_missing(app, bypass_pipeline, monkeypatch):
|
||||
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMemberApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.side_effect = [
|
||||
_account(account_id=str(acct_id)),
|
||||
_tenant(ws_id),
|
||||
None, # member not found
|
||||
]
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}",
|
||||
method="DELETE",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(NotFound):
|
||||
api.delete.__wrapped__.__wrapped__.__wrapped__(
|
||||
api,
|
||||
workspace_id=ws_id,
|
||||
member_id=member_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Update role
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_update_role_happy_path(app, bypass_pipeline, monkeypatch):
|
||||
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMemberRoleApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.side_effect = [
|
||||
_account(account_id=str(acct_id)),
|
||||
_tenant(ws_id),
|
||||
_account(account_id=member_id),
|
||||
]
|
||||
|
||||
update_mock = Mock()
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"TenantService",
|
||||
_tenant_service(update_member_role=update_mock),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role",
|
||||
method="PUT",
|
||||
data=json.dumps({"role": "admin"}),
|
||||
content_type="application/json",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.put.__wrapped__.__wrapped__.__wrapped__(
|
||||
api,
|
||||
workspace_id=ws_id,
|
||||
member_id=member_id,
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
assert body == {"result": "success"}
|
||||
args = update_mock.call_args.args
|
||||
assert args[2] == "admin"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exc", "expected"),
|
||||
[
|
||||
(CannotOperateSelfError("cannot operate self"), BadRequest),
|
||||
(NoPermissionError("no permission"), BadRequest),
|
||||
(RoleAlreadyAssignedError("already"), BadRequest),
|
||||
(MemberNotInTenantError("not in tenant"), NotFound),
|
||||
],
|
||||
)
|
||||
def test_update_role_exception_mapping(app, bypass_pipeline, monkeypatch, exc, expected):
|
||||
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMemberRoleApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.side_effect = [
|
||||
_account(account_id=str(acct_id)),
|
||||
_tenant(ws_id),
|
||||
_account(account_id=member_id),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"TenantService",
|
||||
_tenant_service(update_member_role=Mock(side_effect=exc)),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role",
|
||||
method="PUT",
|
||||
data=json.dumps({"role": "admin"}),
|
||||
content_type="application/json",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(expected):
|
||||
api.put.__wrapped__.__wrapped__.__wrapped__(
|
||||
api,
|
||||
workspace_id=ws_id,
|
||||
member_id=member_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role gate composition — non-member sees 404 even with valid bearer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_non_member_caller_gets_404_on_switch(app, bypass_pipeline, monkeypatch):
|
||||
"""End-to-end: caller has valid account bearer but no membership in
|
||||
the requested workspace. The role gate must short-circuit to 404
|
||||
before any TenantService method is touched."""
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceSwitchApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.execute.return_value.scalar_one_or_none.return_value = None
|
||||
|
||||
switch_mock = Mock()
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"TenantService",
|
||||
_tenant_service(switch_tenant=switch_mock),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.auth.role_gate"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
# Strip only the bearer + surface-gate wrappers; keep the role gate.
|
||||
# Decorator stack (innermost → outermost):
|
||||
# role_gate → accept_subjects → validate_bearer
|
||||
# So `post.__wrapped__` unwraps validate_bearer; we then unwrap
|
||||
# accept_subjects to land on the role-gate wrapper.
|
||||
gated = api.post.__wrapped__.__wrapped__
|
||||
with pytest.raises(NotFound):
|
||||
gated(api, workspace_id=ws_id)
|
||||
|
||||
switch_mock.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _load_tenant rejects archived tenant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_tenant_rejects_archived_workspace(app, bypass_pipeline, monkeypatch):
|
||||
"""Member management against an archived workspace → 404."""
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMembersApi()
|
||||
|
||||
archived = SimpleNamespace(id=ws_id, name="WS", status="archive", created_at=datetime(2026, 5, 18, tzinfo=UTC))
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.return_value = archived
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"TenantService",
|
||||
_tenant_service(),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(NotFound):
|
||||
api.get.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Invite catches AccountRegisterError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_invite_400_when_register_error(app, bypass_pipeline, monkeypatch):
|
||||
"""AccountRegisterError (frozen email, workspace creation blocked) → 400."""
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMembersApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.side_effect = [_account(account_id=str(acct_id)), _tenant(ws_id)]
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"RegisterService",
|
||||
SimpleNamespace(
|
||||
invite_new_member=Mock(side_effect=AccountRegisterError("Workspace is not allowed to create.")),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{ws_id}/members",
|
||||
method="POST",
|
||||
data=json.dumps({"email": "frozen@example.com", "role": "normal"}),
|
||||
content_type="application/json",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(BadRequest):
|
||||
api.post.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
|
||||
@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from configs import dify_config
|
||||
from models.account import Account, AccountStatus, TenantStatus
|
||||
from models.account import Account, AccountStatus, TenantAccountRole, TenantStatus
|
||||
from services.account_service import AccountService, RegisterService, TenantService
|
||||
from services.errors.account import (
|
||||
AccountAlreadyInTenantError,
|
||||
@ -567,6 +567,52 @@ class TestTenantService:
|
||||
with pytest.raises(exception_type):
|
||||
callable_func(*args, **kwargs)
|
||||
|
||||
# ==================== get_account_role_in_tenant Tests ====================
|
||||
# Backs `require_workspace_role`: None => non-member (gate maps to 404),
|
||||
# otherwise the caller's role (gate maps an out-of-set role to 403).
|
||||
|
||||
def test_get_account_role_in_tenant_returns_role_for_member(self):
|
||||
"""A row in TenantAccountJoin yields the caller's role."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.execute.return_value.scalar_one_or_none.return_value = TenantAccountRole.ADMIN
|
||||
|
||||
role = TenantService.get_account_role_in_tenant(mock_session, "account-1", "tenant-1")
|
||||
|
||||
assert role == TenantAccountRole.ADMIN
|
||||
|
||||
def test_get_account_role_in_tenant_returns_none_for_non_member(self):
|
||||
"""No join row => None, so the gate cannot leak the workspace's existence."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.execute.return_value.scalar_one_or_none.return_value = None
|
||||
|
||||
role = TenantService.get_account_role_in_tenant(mock_session, "account-1", "tenant-1")
|
||||
|
||||
assert role is None
|
||||
|
||||
def test_get_account_role_in_tenant_short_circuits_empty_account_id(self):
|
||||
"""None/empty account_id (SSO bearer, missing identity) returns None
|
||||
without ever touching the session."""
|
||||
mock_session = MagicMock()
|
||||
|
||||
assert TenantService.get_account_role_in_tenant(mock_session, None, "tenant-1") is None
|
||||
mock_session.execute.assert_not_called()
|
||||
|
||||
def test_get_account_role_in_tenant_query_is_scoped(self):
|
||||
"""The lookup must filter on BOTH tenant_id and account_id — otherwise
|
||||
a member of workspace A could read their role for workspace B. Compile
|
||||
the statement and assert both identifiers appear in the WHERE clause."""
|
||||
account_id = "11111111-1111-1111-1111-111111111111"
|
||||
tenant_id = "22222222-2222-2222-2222-222222222222"
|
||||
mock_session = MagicMock()
|
||||
mock_session.execute.return_value.scalar_one_or_none.return_value = TenantAccountRole.NORMAL
|
||||
|
||||
TenantService.get_account_role_in_tenant(mock_session, account_id, tenant_id)
|
||||
|
||||
stmt = mock_session.execute.call_args.args[0]
|
||||
compiled = str(stmt.compile(compile_kwargs={"literal_binds": True}))
|
||||
assert account_id in compiled
|
||||
assert tenant_id in compiled
|
||||
|
||||
# ==================== Tenant Creation Tests ====================
|
||||
|
||||
def test_create_owner_tenant_if_not_exist_new_user(
|
||||
|
||||
@ -103,7 +103,7 @@ import { ErrorCode } from '../../errors/codes.js'
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: 'workspace id required',
|
||||
hint: 'pass --workspace or run \'difyctl auth use <id>\'',
|
||||
hint: 'pass --workspace or run \'difyctl use workspace <id>\'',
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@ -8,8 +8,15 @@ export class AccountSessionsClient {
|
||||
this.http = http
|
||||
}
|
||||
|
||||
async list(): Promise<SessionListResponse> {
|
||||
return this.http.get('account/sessions').json<SessionListResponse>()
|
||||
async list(q?: { page?: number, limit?: number }): Promise<SessionListResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (q?.page !== undefined)
|
||||
params.set('page', String(q.page))
|
||||
if (q?.limit !== undefined)
|
||||
params.set('limit', String(q.limit))
|
||||
const hasParams = Array.from(params.keys()).length > 0
|
||||
const opts = hasParams ? { searchParams: params } : undefined
|
||||
return this.http.get('account/sessions', opts).json<SessionListResponse>()
|
||||
}
|
||||
|
||||
async revoke(sessionId: string): Promise<void> {
|
||||
|
||||
280
cli/src/api/members.test.ts
Normal file
280
cli/src/api/members.test.ts
Normal file
@ -0,0 +1,280 @@
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import * as http from 'node:http'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { isBaseError } from '../errors/base.js'
|
||||
import { createClient } from '../http/client.js'
|
||||
import { MembersClient } from './members.js'
|
||||
|
||||
type StubServer = {
|
||||
url: string
|
||||
lastRequest: { method?: string, url?: string, body?: string }
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
function jsonResponder(
|
||||
status: number,
|
||||
body: unknown,
|
||||
captured: StubServer['lastRequest'],
|
||||
): http.RequestListener {
|
||||
return (req, res) => {
|
||||
captured.method = req.method
|
||||
captured.url = req.url
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', c => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
captured.body = Buffer.concat(chunks).toString('utf8')
|
||||
const payload = JSON.stringify(body)
|
||||
res.writeHead(status, {
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(payload),
|
||||
})
|
||||
res.end(payload)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function startServer(handler: http.RequestListener): Promise<StubServer> {
|
||||
const captured: StubServer['lastRequest'] = {}
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => handler(req, res))
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address() as AddressInfo
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${addr.port}`,
|
||||
lastRequest: captured,
|
||||
stop: () =>
|
||||
new Promise<void>((res, rej) => server.close(err => (err ? rej(err) : res()))),
|
||||
})
|
||||
})
|
||||
server.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function makeClient(host: string): MembersClient {
|
||||
return new MembersClient(createClient({ host, bearer: 'dfoa_test' }))
|
||||
}
|
||||
|
||||
describe('MembersClient.list', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('GETs /workspaces/<id>/members and returns parsed envelope', async () => {
|
||||
const captured: StubServer['lastRequest'] = {}
|
||||
stub = await startServer(
|
||||
jsonResponder(
|
||||
200,
|
||||
{
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 1,
|
||||
has_more: false,
|
||||
data: [
|
||||
{ id: 'm-1', name: 'Mia', email: 'mia@e.com', role: 'admin', status: 'active' },
|
||||
],
|
||||
},
|
||||
captured,
|
||||
),
|
||||
)
|
||||
stub.lastRequest = captured
|
||||
|
||||
const result = await makeClient(stub.url).list('ws-1')
|
||||
expect(captured.method).toBe('GET')
|
||||
expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/members')
|
||||
expect(result.data[0].email).toBe('mia@e.com')
|
||||
})
|
||||
|
||||
it('URL-encodes workspace id', async () => {
|
||||
const captured: StubServer['lastRequest'] = {}
|
||||
stub = await startServer(
|
||||
jsonResponder(200, { page: 1, limit: 20, total: 0, has_more: false, data: [] }, captured),
|
||||
)
|
||||
stub.lastRequest = captured
|
||||
|
||||
await makeClient(stub.url).list('ws with space')
|
||||
expect(captured.url).toBe('/openapi/v1/workspaces/ws%20with%20space/members')
|
||||
})
|
||||
|
||||
it('forwards page/limit as query params', async () => {
|
||||
const captured: StubServer['lastRequest'] = {}
|
||||
stub = await startServer(
|
||||
jsonResponder(200, { page: 2, limit: 50, total: 0, has_more: false, data: [] }, captured),
|
||||
)
|
||||
stub.lastRequest = captured
|
||||
|
||||
await makeClient(stub.url).list('ws-1', { page: 2, limit: 50 })
|
||||
expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/members?page=2&limit=50')
|
||||
})
|
||||
|
||||
it('propagates server 403 as HTTPError', async () => {
|
||||
const captured: StubServer['lastRequest'] = {}
|
||||
stub = await startServer(jsonResponder(403, { error: 'forbidden' }, captured))
|
||||
|
||||
await expect(makeClient(stub.url).list('ws-1')).rejects.toSatisfy(
|
||||
err => isBaseError(err) && err.httpStatus === 403,
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates 404 as classified BaseError', async () => {
|
||||
const captured: StubServer['lastRequest'] = {}
|
||||
stub = await startServer(jsonResponder(404, { error: 'not found' }, captured))
|
||||
|
||||
await expect(makeClient(stub.url).list('ws-missing')).rejects.toSatisfy(
|
||||
err => isBaseError(err) && err.httpStatus === 404,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MembersClient.invite', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('POSTs JSON body and returns parsed invite response', async () => {
|
||||
const captured: StubServer['lastRequest'] = {}
|
||||
stub = await startServer(
|
||||
jsonResponder(
|
||||
201,
|
||||
{
|
||||
result: 'success',
|
||||
email: 'new@e.com',
|
||||
role: 'normal',
|
||||
member_id: 'acct-9',
|
||||
invite_url: 'https://console.example.com/activate?email=new&token=tok',
|
||||
tenant_id: 'ws-1',
|
||||
},
|
||||
captured,
|
||||
),
|
||||
)
|
||||
stub.lastRequest = captured
|
||||
|
||||
const result = await makeClient(stub.url).invite('ws-1', {
|
||||
email: 'new@e.com',
|
||||
role: 'normal',
|
||||
})
|
||||
expect(captured.method).toBe('POST')
|
||||
expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/members')
|
||||
expect(JSON.parse(captured.body ?? '{}')).toEqual({
|
||||
email: 'new@e.com',
|
||||
role: 'normal',
|
||||
})
|
||||
expect(result.member_id).toBe('acct-9')
|
||||
expect(result.invite_url).toContain('token=tok')
|
||||
})
|
||||
|
||||
it('propagates 400 (already in tenant) as HTTPError', async () => {
|
||||
const captured: StubServer['lastRequest'] = {}
|
||||
stub = await startServer(jsonResponder(400, { error: 'already in tenant' }, captured))
|
||||
|
||||
await expect(
|
||||
makeClient(stub.url).invite('ws-1', { email: 'u@e.com', role: 'normal' }),
|
||||
).rejects.toSatisfy(err => isBaseError(err) && err.httpStatus === 400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MembersClient.remove', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('DELETEs member by id and returns success', async () => {
|
||||
const captured: StubServer['lastRequest'] = {}
|
||||
stub = await startServer(jsonResponder(200, { result: 'success' }, captured))
|
||||
stub.lastRequest = captured
|
||||
|
||||
const result = await makeClient(stub.url).remove('ws-1', 'm-1')
|
||||
expect(captured.method).toBe('DELETE')
|
||||
expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1')
|
||||
expect(result.result).toBe('success')
|
||||
})
|
||||
|
||||
it('propagates 400 (cannot operate self / cannot remove owner)', async () => {
|
||||
const captured: StubServer['lastRequest'] = {}
|
||||
stub = await startServer(jsonResponder(400, { error: 'cannot operate self' }, captured))
|
||||
|
||||
await expect(makeClient(stub.url).remove('ws-1', 'm-1')).rejects.toSatisfy(
|
||||
err => isBaseError(err) && err.httpStatus === 400,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MembersClient.updateRole', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('PUTs role payload to /role subresource', async () => {
|
||||
const captured: StubServer['lastRequest'] = {}
|
||||
stub = await startServer(jsonResponder(200, { result: 'success' }, captured))
|
||||
stub.lastRequest = captured
|
||||
|
||||
const result = await makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' })
|
||||
expect(captured.method).toBe('PUT')
|
||||
expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1/role')
|
||||
expect(JSON.parse(captured.body ?? '{}')).toEqual({ role: 'admin' })
|
||||
expect(result.result).toBe('success')
|
||||
})
|
||||
|
||||
it('propagates 400 (admin cannot demote owner)', async () => {
|
||||
const captured: StubServer['lastRequest'] = {}
|
||||
stub = await startServer(jsonResponder(400, { error: 'no permission' }, captured))
|
||||
|
||||
await expect(
|
||||
makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' }),
|
||||
).rejects.toSatisfy(err => isBaseError(err) && err.httpStatus === 400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspacesClient.switch (integration with stub)', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('POSTs /workspaces/<id>/switch and returns workspace detail', async () => {
|
||||
const captured: StubServer['lastRequest'] = {}
|
||||
stub = await startServer(
|
||||
jsonResponder(
|
||||
200,
|
||||
{
|
||||
id: 'ws-1',
|
||||
name: 'Workspace 1',
|
||||
role: 'owner',
|
||||
status: 'normal',
|
||||
current: true,
|
||||
created_at: '2026-05-18T00:00:00Z',
|
||||
},
|
||||
captured,
|
||||
),
|
||||
)
|
||||
stub.lastRequest = captured
|
||||
|
||||
const { WorkspacesClient } = await import('./workspaces.js')
|
||||
const client = new WorkspacesClient(createClient({ host: stub.url, bearer: 'dfoa_test' }))
|
||||
const result = await client.switch('ws-1')
|
||||
expect(captured.method).toBe('POST')
|
||||
expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/switch')
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('propagates 404 (non-member)', async () => {
|
||||
const captured: StubServer['lastRequest'] = {}
|
||||
stub = await startServer(jsonResponder(404, { error: 'not found' }, captured))
|
||||
|
||||
const { WorkspacesClient } = await import('./workspaces.js')
|
||||
const client = new WorkspacesClient(createClient({ host: stub.url, bearer: 'dfoa_test' }))
|
||||
await expect(client.switch('ws-x')).rejects.toSatisfy(
|
||||
err => isBaseError(err) && err.httpStatus === 404,
|
||||
)
|
||||
})
|
||||
})
|
||||
61
cli/src/api/members.ts
Normal file
61
cli/src/api/members.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import type {
|
||||
MemberActionResponse,
|
||||
MemberInvitePayload,
|
||||
MemberInviteResponse,
|
||||
MemberListResponse,
|
||||
MemberRoleUpdatePayload,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
|
||||
/**
|
||||
* Thin client for /openapi/v1/workspaces/<id>/members.
|
||||
*
|
||||
* Errors are surfaced as ky HTTPErrors with the server's status code
|
||||
* (400/403/404/422). The CLI's AuthedCommand base layer maps those to
|
||||
* user-visible messages — clients never swallow status codes here.
|
||||
*/
|
||||
export class MembersClient {
|
||||
private readonly http: KyInstance
|
||||
|
||||
constructor(http: KyInstance) {
|
||||
this.http = http
|
||||
}
|
||||
|
||||
async list(workspaceId: string, q?: { page?: number, limit?: number }): Promise<MemberListResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (q?.page !== undefined)
|
||||
params.set('page', String(q.page))
|
||||
if (q?.limit !== undefined)
|
||||
params.set('limit', String(q.limit))
|
||||
const hasParams = Array.from(params.keys()).length > 0
|
||||
const opts = hasParams ? { searchParams: params } : undefined
|
||||
return this.http
|
||||
.get(`workspaces/${encodeURIComponent(workspaceId)}/members`, opts)
|
||||
.json<MemberListResponse>()
|
||||
}
|
||||
|
||||
async invite(workspaceId: string, payload: MemberInvitePayload): Promise<MemberInviteResponse> {
|
||||
return this.http
|
||||
.post(`workspaces/${encodeURIComponent(workspaceId)}/members`, { json: payload })
|
||||
.json<MemberInviteResponse>()
|
||||
}
|
||||
|
||||
async remove(workspaceId: string, memberId: string): Promise<MemberActionResponse> {
|
||||
return this.http
|
||||
.delete(`workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}`)
|
||||
.json<MemberActionResponse>()
|
||||
}
|
||||
|
||||
async updateRole(
|
||||
workspaceId: string,
|
||||
memberId: string,
|
||||
payload: MemberRoleUpdatePayload,
|
||||
): Promise<MemberActionResponse> {
|
||||
return this.http
|
||||
.put(
|
||||
`workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}/role`,
|
||||
{ json: payload },
|
||||
)
|
||||
.json<MemberActionResponse>()
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import type { WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { WorkspaceDetailResponse, WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
|
||||
export class WorkspacesClient {
|
||||
@ -11,4 +11,19 @@ export class WorkspacesClient {
|
||||
async list(): Promise<WorkspaceListResponse> {
|
||||
return this.http.get('workspaces').json<WorkspaceListResponse>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side workspace switch via OpenAPI POST
|
||||
* `/workspaces/{id}/switch` — the bearer-authed equivalent of the
|
||||
* console's POST `/workspaces/switch`. The server updates the caller's
|
||||
* `current` tenant_account_join row. Callers MUST refresh their local
|
||||
* `hosts.yml` only after this resolves — never fall back to a local
|
||||
* write if the request fails, or `hosts.yml` will drift from the
|
||||
* server's state.
|
||||
*/
|
||||
async switch(workspaceId: string): Promise<WorkspaceDetailResponse> {
|
||||
return this.http
|
||||
.post(`workspaces/${encodeURIComponent(workspaceId)}/switch`)
|
||||
.json<WorkspaceDetailResponse>()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { DifyMock } from '../../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { AccountSessionsClient } from '../../../../api/account-sessions.js'
|
||||
import type { HostsBundle } from '../../../../auth/hosts.js'
|
||||
import type { TokenStore } from '../../../../auth/store.js'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startMock } from '../../../../../test/fixtures/dify-mock/server.js'
|
||||
import { saveHosts } from '../../../../auth/hosts.js'
|
||||
import { createClient } from '../../../../http/client.js'
|
||||
import { bufferStreams } from '../../../../io/streams.js'
|
||||
import { runDevicesList, runDevicesRevoke } from './devices.js'
|
||||
import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js'
|
||||
|
||||
class MemStore implements TokenStore {
|
||||
readonly entries = new Map<string, string>()
|
||||
@ -187,3 +189,47 @@ describe('runDevicesRevoke', () => {
|
||||
.toThrow(/specify a device label/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listAllSessions', () => {
|
||||
const row = (id: string, label = `dev-${id}`): SessionRow => ({
|
||||
id,
|
||||
prefix: 'dfoa_xxx',
|
||||
client_id: 'difyctl',
|
||||
device_label: label,
|
||||
created_at: null,
|
||||
last_used_at: null,
|
||||
expires_at: null,
|
||||
})
|
||||
|
||||
function stubClient(pages: readonly SessionListResponse[]): { client: AccountSessionsClient, list: ReturnType<typeof vi.fn> } {
|
||||
const list = vi.fn(async (q?: { page?: number, limit?: number }) => {
|
||||
const page = q?.page ?? 1
|
||||
const env = pages[page - 1]
|
||||
if (env === undefined)
|
||||
throw new Error(`stub: no page ${page}`)
|
||||
return env
|
||||
})
|
||||
return { client: { list } as unknown as AccountSessionsClient, list }
|
||||
}
|
||||
|
||||
it('exhausts pages until has_more=false', async () => {
|
||||
const { client, list } = stubClient([
|
||||
{ page: 1, limit: 200, total: 250, has_more: true, data: Array.from({ length: 200 }, (_, i) => row(`s-${i}`)) },
|
||||
{ page: 2, limit: 200, total: 250, has_more: false, data: Array.from({ length: 50 }, (_, i) => row(`s-${200 + i}`)) },
|
||||
])
|
||||
const all = await listAllSessions(client)
|
||||
expect(all.length).toBe(250)
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
expect(list).toHaveBeenNthCalledWith(1, { page: 1, limit: 200 })
|
||||
expect(list).toHaveBeenNthCalledWith(2, { page: 2, limit: 200 })
|
||||
})
|
||||
|
||||
it('single page (has_more=false): one call', async () => {
|
||||
const { client, list } = stubClient([
|
||||
{ page: 1, limit: 200, total: 3, has_more: false, data: [row('a'), row('b'), row('c')] },
|
||||
])
|
||||
const all = await listAllSessions(client)
|
||||
expect(all.length).toBe(3)
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -11,28 +11,64 @@ import { BaseError } from '../../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from '../../../../io/color.js'
|
||||
import { runWithSpinner } from '../../../../io/spinner.js'
|
||||
import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '../../../../limit/limit.js'
|
||||
|
||||
export type DevicesListOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly http: KyInstance
|
||||
readonly json?: boolean
|
||||
readonly page?: number
|
||||
readonly limitRaw?: string
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
}
|
||||
|
||||
export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
|
||||
const b = requireLogin(opts.bundle)
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
const env = await runWithSpinner(
|
||||
const env = opts.envLookup ?? ((k: string) => process.env[k])
|
||||
const limit = resolveLimit(opts.limitRaw, env)
|
||||
const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page
|
||||
const envelope = await runWithSpinner(
|
||||
{ io: opts.io, label: 'Fetching devices' },
|
||||
() => sessions.list(),
|
||||
() => sessions.list({ page, limit }),
|
||||
)
|
||||
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${JSON.stringify(env)}\n`)
|
||||
opts.io.out.write(`${JSON.stringify(envelope)}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
opts.io.out.write(renderTable(env.data, b.token_id ?? ''))
|
||||
opts.io.out.write(renderTable(envelope.data, b.token_id ?? ''))
|
||||
}
|
||||
|
||||
function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number {
|
||||
if (raw !== undefined && raw !== '')
|
||||
return parseLimit(raw, '--limit')
|
||||
const envValue = env('DIFY_LIMIT')
|
||||
if (envValue !== undefined && envValue !== '')
|
||||
return parseLimit(envValue, 'DIFY_LIMIT')
|
||||
return LIMIT_DEFAULT
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches every session across all pages. Used by revoke paths so that a
|
||||
* session sitting on page 2+ is still findable / revocable. Uses the max
|
||||
* page size (LIMIT_MAX) to minimize round-trips.
|
||||
*/
|
||||
export async function listAllSessions(client: AccountSessionsClient): Promise<readonly SessionRow[]> {
|
||||
const out: SessionRow[] = []
|
||||
let page = 1
|
||||
// Hard guard against a misbehaving server that lies about has_more.
|
||||
const MAX_PAGES = 100
|
||||
while (page <= MAX_PAGES) {
|
||||
const env = await client.list({ page, limit: LIMIT_MAX })
|
||||
out.push(...env.data)
|
||||
if (!env.has_more)
|
||||
return out
|
||||
page++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export type DevicesRevokeOptions = {
|
||||
@ -58,8 +94,8 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
|
||||
}
|
||||
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
const env = await sessions.list()
|
||||
const { ids, selfHit } = pickTargets(env.data, opts, b.token_id ?? '')
|
||||
const rows = await listAllSessions(sessions)
|
||||
const { ids, selfHit } = pickTargets(rows, opts, b.token_id ?? '')
|
||||
if (ids.length === 0) {
|
||||
opts.io.out.write('no sessions to revoke\n')
|
||||
return
|
||||
|
||||
@ -9,17 +9,27 @@ export default class DevicesList extends DifyCommand {
|
||||
static override examples = [
|
||||
'<%= config.bin %> auth devices list',
|
||||
'<%= config.bin %> auth devices list --json',
|
||||
'<%= config.bin %> auth devices list --page 2 --limit 50',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
'http-retry': httpRetryFlag,
|
||||
'json': Flags.boolean({ description: 'emit JSON', default: false }),
|
||||
'page': Flags.integer({ description: 'page number', default: 1 }),
|
||||
'limit': Flags.string({ description: 'page size [1..200]' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(DevicesList, argv)
|
||||
const format = flags.json ? 'json' : ''
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
await runDevicesList({ io: ctx.io, bundle: ctx.bundle, http: ctx.http, json: flags.json })
|
||||
await runDevicesList({
|
||||
io: ctx.io,
|
||||
bundle: ctx.bundle,
|
||||
http: ctx.http,
|
||||
json: flags.json,
|
||||
page: flags.page,
|
||||
limitRaw: flags.limit,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
import { loadHosts } from '../../../auth/hosts.js'
|
||||
import { resolveConfigDir } from '../../../config/dir.js'
|
||||
import { Args } from '../../../framework/flags.js'
|
||||
import { realStreams } from '../../../io/streams.js'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { runUse } from './use.js'
|
||||
|
||||
export default class Use extends DifyCommand {
|
||||
static override description = 'Switch the active workspace for the current host'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> auth use ws-abc123',
|
||||
]
|
||||
|
||||
static override args = {
|
||||
workspaceId: Args.string({ description: 'workspace id to activate', required: true }),
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { args } = this.parse(Use, argv)
|
||||
const configDir = resolveConfigDir()
|
||||
const bundle = await loadHosts(configDir)
|
||||
await runUse({ configDir, io: realStreams(), bundle, workspaceId: args.workspaceId })
|
||||
}
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { loadHosts, saveHosts } from '../../../auth/hosts.js'
|
||||
import { bufferStreams } from '../../../io/streams.js'
|
||||
import { runUse } from './use.js'
|
||||
|
||||
function accountBundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
token_id: 'tok-1',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('runUse', () => {
|
||||
let configDir: string
|
||||
beforeEach(async () => {
|
||||
configDir = await mkdtemp(join(tmpdir(), 'difyctl-use-'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('switches workspace + persists hosts.yml', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = accountBundle()
|
||||
await saveHosts(configDir, b)
|
||||
const next = await runUse({ configDir, io, bundle: b, workspaceId: 'ws-2' })
|
||||
expect(next.workspace).toEqual({ id: 'ws-2', name: 'Other', role: 'normal' })
|
||||
const reloaded = await loadHosts(configDir)
|
||||
expect(reloaded?.workspace?.id).toBe('ws-2')
|
||||
expect(io.outBuf()).toContain('Switched to workspace Other (ws-2)')
|
||||
})
|
||||
|
||||
it('not-logged-in: throws NotLoggedIn', async () => {
|
||||
const io = bufferStreams()
|
||||
await expect(runUse({ configDir, io, bundle: undefined, workspaceId: 'ws-1' }))
|
||||
.rejects
|
||||
.toThrow(/not logged in/)
|
||||
})
|
||||
|
||||
it('sso: throws workspace-unavailable', async () => {
|
||||
const io = bufferStreams()
|
||||
const b: HostsBundle = {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoe_test' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
}
|
||||
await expect(runUse({ configDir, io, bundle: b, workspaceId: 'ws-1' }))
|
||||
.rejects
|
||||
.toThrow(/workspace context unavailable/)
|
||||
})
|
||||
|
||||
it('unknown workspace: throws UsageMissingArg', async () => {
|
||||
const io = bufferStreams()
|
||||
await expect(runUse({ configDir, io, bundle: accountBundle(), workspaceId: 'ws-bogus' }))
|
||||
.rejects
|
||||
.toThrow(/ws-bogus.*not found/)
|
||||
})
|
||||
})
|
||||
@ -1,49 +0,0 @@
|
||||
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../io/streams.js'
|
||||
import { saveHosts } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from '../../../io/color.js'
|
||||
|
||||
export type UseOptions = {
|
||||
readonly configDir: string
|
||||
readonly io: IOStreams
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly workspaceId: string
|
||||
}
|
||||
|
||||
export async function runUse(opts: UseOptions): Promise<HostsBundle> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const b = opts.bundle
|
||||
if (b === undefined || b.tokens?.bearer === undefined || b.tokens.bearer === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
}
|
||||
if (b.external_subject !== undefined) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: 'workspace context unavailable for external SSO sessions',
|
||||
hint: 'external SSO subjects don\'t carry tenant memberships in difyctl',
|
||||
})
|
||||
}
|
||||
|
||||
const found = (b.available_workspaces ?? []).find(w => w.id === opts.workspaceId)
|
||||
if (found === undefined) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: `workspace "${opts.workspaceId}" not found in available_workspaces; run 'difyctl auth status' to list`,
|
||||
})
|
||||
}
|
||||
|
||||
const next: HostsBundle = { ...b, workspace: pickWorkspace(found) }
|
||||
await saveHosts(opts.configDir, next)
|
||||
opts.io.out.write(`${cs.successIcon()} Switched to workspace ${found.name} (${found.id})\n`)
|
||||
return next
|
||||
}
|
||||
|
||||
function pickWorkspace(w: Workspace): Workspace {
|
||||
return { id: w.id, name: w.name, role: w.role }
|
||||
}
|
||||
23
cli/src/commands/create/member/handlers.ts
Normal file
23
cli/src/commands/create/member/handlers.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import type { MemberInviteResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
|
||||
export class InviteOutput {
|
||||
readonly response: MemberInviteResponse
|
||||
readonly textLine: string
|
||||
|
||||
constructor(response: MemberInviteResponse, textLine: string) {
|
||||
this.response = response
|
||||
this.textLine = textLine
|
||||
}
|
||||
|
||||
text(): string {
|
||||
return this.textLine
|
||||
}
|
||||
|
||||
json(): MemberInviteResponse {
|
||||
return this.response
|
||||
}
|
||||
|
||||
name(): string {
|
||||
return this.response.member_id
|
||||
}
|
||||
}
|
||||
40
cli/src/commands/create/member/index.ts
Normal file
40
cli/src/commands/create/member/index.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { formatted } from '../../../framework/output.js'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { httpRetryFlag } from '../../_shared/global-flags.js'
|
||||
import { runCreateMember } from './run.js'
|
||||
|
||||
export default class CreateMember extends DifyCommand {
|
||||
static override description = 'Invite a member to the active (or specified) workspace by email'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> create member --email user@example.com --role normal',
|
||||
'<%= config.bin %> create member --email user@example.com --role admin -w ws-1',
|
||||
'<%= config.bin %> create member --email user@example.com --role normal -o json',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
'email': Flags.string({ description: 'invitee email address', required: true }),
|
||||
'role': Flags.string({
|
||||
description: 'role to assign (normal|admin); owner is not assignable here',
|
||||
required: true,
|
||||
}),
|
||||
'workspace': Flags.string({
|
||||
char: 'w',
|
||||
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
|
||||
}),
|
||||
'http-retry': httpRetryFlag,
|
||||
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|text)', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]) {
|
||||
const { flags } = this.parse(CreateMember, argv)
|
||||
const format = flags.output
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runCreateMember(
|
||||
{ email: flags.email, role: flags.role, workspace: flags.workspace, format },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
}
|
||||
102
cli/src/commands/create/member/run.test.ts
Normal file
102
cli/src/commands/create/member/run.test.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../io/streams.js'
|
||||
import { runCreateMember } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
}
|
||||
}
|
||||
|
||||
function fakeClient() {
|
||||
return {
|
||||
invite: vi.fn((_ws: string, body: { email: string, role: string }) =>
|
||||
Promise.resolve({
|
||||
result: 'success' as const,
|
||||
email: body.email.toLowerCase(),
|
||||
role: body.role,
|
||||
member_id: 'acct-new',
|
||||
invite_url: 'https://console.example.com/activate?email=x&token=tok',
|
||||
tenant_id: 'ws-1',
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
describe('runCreateMember', () => {
|
||||
it('happy path: POSTs invite, returns InviteOutput with text/json/name', async () => {
|
||||
const client = fakeClient()
|
||||
const result = await runCreateMember(
|
||||
{ email: 'new@example.com', role: 'normal' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
)
|
||||
expect(client.invite).toHaveBeenCalledWith('ws-1', { email: 'new@example.com', role: 'normal' })
|
||||
expect(result.data.text()).toMatch(/Invited new@example\.com as normal/)
|
||||
expect(result.data.name()).toBe('acct-new')
|
||||
expect(result.data.json()).toMatchObject({
|
||||
email: 'new@example.com',
|
||||
role: 'normal',
|
||||
member_id: 'acct-new',
|
||||
invite_url: 'https://console.example.com/activate?email=x&token=tok',
|
||||
tenant_id: 'ws-1',
|
||||
})
|
||||
expect(result.workspaceId).toBe('ws-1')
|
||||
})
|
||||
|
||||
it('rejects unknown role before any HTTP call', async () => {
|
||||
const client = fakeClient()
|
||||
await expect(
|
||||
runCreateMember(
|
||||
{ email: 'new@example.com', role: 'owner' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/invalid --role/)
|
||||
expect(client.invite).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects empty email', async () => {
|
||||
const client = fakeClient()
|
||||
await expect(
|
||||
runCreateMember(
|
||||
{ email: '', role: 'normal' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/--email is required/)
|
||||
expect(client.invite).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('-w flag overrides resolved workspace', async () => {
|
||||
const client = fakeClient()
|
||||
await runCreateMember(
|
||||
{ email: 'new@example.com', role: 'admin', workspace: 'ws-9' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
)
|
||||
expect(client.invite).toHaveBeenCalledWith('ws-9', { email: 'new@example.com', role: 'admin' })
|
||||
})
|
||||
})
|
||||
75
cli/src/commands/create/member/run.ts
Normal file
75
cli/src/commands/create/member/run.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../io/streams.js'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from '../../../io/color.js'
|
||||
import { runWithSpinner } from '../../../io/spinner.js'
|
||||
import { nullStreams } from '../../../io/streams.js'
|
||||
import { resolveWorkspaceId } from '../../../workspace/resolver.js'
|
||||
import { InviteOutput } from './handlers.js'
|
||||
|
||||
export type CreateMemberOptions = {
|
||||
readonly email: string
|
||||
readonly role: string
|
||||
readonly workspace?: string
|
||||
readonly format?: string
|
||||
}
|
||||
|
||||
export type CreateMemberDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
readonly membersFactory?: (http: KyInstance) => MembersClient
|
||||
}
|
||||
|
||||
export type CreateMemberResult = {
|
||||
readonly data: InviteOutput
|
||||
readonly workspaceId: string
|
||||
}
|
||||
|
||||
// `owner` is intentionally absent — ownership transfer is console-only.
|
||||
const ASSIGNABLE_ROLES = new Set(['normal', 'admin'])
|
||||
|
||||
export async function runCreateMember(
|
||||
opts: CreateMemberOptions,
|
||||
deps: CreateMemberDeps,
|
||||
): Promise<CreateMemberResult> {
|
||||
if (opts.email === undefined || opts.email === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: '--email is required',
|
||||
})
|
||||
}
|
||||
if (!ASSIGNABLE_ROLES.has(opts.role)) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `invalid --role "${opts.role}"`,
|
||||
hint: 'expected: normal | admin (ownership transfer is console-only)',
|
||||
})
|
||||
}
|
||||
|
||||
const env = deps.envLookup ?? ((k: string) => process.env[k])
|
||||
const factory = deps.membersFactory ?? ((h: KyInstance) => new MembersClient(h))
|
||||
const io = deps.io ?? nullStreams()
|
||||
const cs = colorScheme(colorEnabled(io.isErrTTY))
|
||||
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
bundle: deps.bundle,
|
||||
})
|
||||
|
||||
const response = await runWithSpinner(
|
||||
{ io, label: `Inviting ${opts.email}` },
|
||||
() => factory(deps.http).invite(wsId, {
|
||||
email: opts.email,
|
||||
role: opts.role as 'normal' | 'admin',
|
||||
}),
|
||||
)
|
||||
|
||||
const textLine = `${cs.successIcon()} Invited ${response.email} as ${response.role}\n`
|
||||
return { data: new InviteOutput(response, textLine), workspaceId: wsId }
|
||||
}
|
||||
26
cli/src/commands/delete/member/handlers.ts
Normal file
26
cli/src/commands/delete/member/handlers.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export type DeletedMemberPayload = {
|
||||
readonly id: string
|
||||
readonly deleted: true
|
||||
}
|
||||
|
||||
export class DeleteMemberOutput {
|
||||
readonly payload: DeletedMemberPayload
|
||||
readonly textLine: string
|
||||
|
||||
constructor(memberId: string, textLine: string) {
|
||||
this.payload = { id: memberId, deleted: true }
|
||||
this.textLine = textLine
|
||||
}
|
||||
|
||||
text(): string {
|
||||
return this.textLine
|
||||
}
|
||||
|
||||
json(): DeletedMemberPayload {
|
||||
return this.payload
|
||||
}
|
||||
|
||||
name(): string {
|
||||
return this.payload.id
|
||||
}
|
||||
}
|
||||
40
cli/src/commands/delete/member/index.ts
Normal file
40
cli/src/commands/delete/member/index.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { Args, Flags } from '../../../framework/flags.js'
|
||||
import { formatted } from '../../../framework/output.js'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { httpRetryFlag } from '../../_shared/global-flags.js'
|
||||
import { runDeleteMember } from './run.js'
|
||||
|
||||
export default class DeleteMember extends DifyCommand {
|
||||
static override description = 'Remove a member from the active (or specified) workspace'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> delete member acct-1',
|
||||
'<%= config.bin %> delete member acct-1 -w ws-1',
|
||||
'<%= config.bin %> delete member acct-1 -o json',
|
||||
]
|
||||
|
||||
static override args = {
|
||||
memberId: Args.string({ description: 'account id of the member to remove', required: true }),
|
||||
}
|
||||
|
||||
static override flags = {
|
||||
'workspace': Flags.string({
|
||||
char: 'w',
|
||||
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
|
||||
}),
|
||||
'http-retry': httpRetryFlag,
|
||||
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|text)', default: '' }),
|
||||
'yes': Flags.boolean({ char: 'y', description: 'skip confirmation prompt', default: false }),
|
||||
}
|
||||
|
||||
async run(argv: string[]) {
|
||||
const { args, flags } = this.parse(DeleteMember, argv)
|
||||
const format = flags.output
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runDeleteMember(
|
||||
{ memberId: args.memberId, workspace: flags.workspace, format, yes: flags.yes },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
}
|
||||
72
cli/src/commands/delete/member/run.test.ts
Normal file
72
cli/src/commands/delete/member/run.test.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../io/streams.js'
|
||||
import { runDeleteMember } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
}
|
||||
}
|
||||
|
||||
function fakeClient() {
|
||||
return {
|
||||
remove: vi.fn(() => Promise.resolve({ result: 'success' as const })),
|
||||
}
|
||||
}
|
||||
|
||||
describe('runDeleteMember', () => {
|
||||
it('happy path: DELETE, returns DeleteMemberOutput with text/json/name', async () => {
|
||||
const client = fakeClient()
|
||||
const result = await runDeleteMember(
|
||||
{ memberId: 'acct-2' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
)
|
||||
expect(client.remove).toHaveBeenCalledExactlyOnceWith('ws-1', 'acct-2')
|
||||
expect(result.data.text()).toMatch(/Removed acct-2/)
|
||||
expect(result.data.name()).toBe('acct-2')
|
||||
expect(result.data.json()).toEqual({ id: 'acct-2', deleted: true })
|
||||
expect(result.workspaceId).toBe('ws-1')
|
||||
})
|
||||
|
||||
it('-w flag overrides resolved workspace', async () => {
|
||||
const client = fakeClient()
|
||||
await runDeleteMember(
|
||||
{ memberId: 'acct-2', workspace: 'ws-9' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
)
|
||||
expect(client.remove).toHaveBeenCalledWith('ws-9', 'acct-2')
|
||||
})
|
||||
|
||||
it('rejects empty member id before any HTTP call', async () => {
|
||||
const client = fakeClient()
|
||||
await expect(
|
||||
runDeleteMember(
|
||||
{ memberId: '' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/member id is required/)
|
||||
expect(client.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
90
cli/src/commands/delete/member/run.ts
Normal file
90
cli/src/commands/delete/member/run.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../io/streams.js'
|
||||
import * as readline from 'node:readline'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from '../../../io/color.js'
|
||||
import { runWithSpinner } from '../../../io/spinner.js'
|
||||
import { nullStreams } from '../../../io/streams.js'
|
||||
import { resolveWorkspaceId } from '../../../workspace/resolver.js'
|
||||
import { DeleteMemberOutput } from './handlers.js'
|
||||
|
||||
export type DeleteMemberOptions = {
|
||||
readonly memberId: string
|
||||
readonly workspace?: string
|
||||
readonly format?: string
|
||||
readonly yes?: boolean
|
||||
}
|
||||
|
||||
export type DeleteMemberDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
readonly membersFactory?: (http: KyInstance) => MembersClient
|
||||
}
|
||||
|
||||
export type DeleteMemberResult = {
|
||||
readonly data: DeleteMemberOutput
|
||||
readonly workspaceId: string
|
||||
}
|
||||
|
||||
export async function runDeleteMember(
|
||||
opts: DeleteMemberOptions,
|
||||
deps: DeleteMemberDeps,
|
||||
): Promise<DeleteMemberResult> {
|
||||
if (opts.memberId === undefined || opts.memberId === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: 'member id is required',
|
||||
hint: 'pass it positionally: difyctl delete member <member-id>',
|
||||
})
|
||||
}
|
||||
|
||||
const env = deps.envLookup ?? ((k: string) => process.env[k])
|
||||
const factory = deps.membersFactory ?? ((h: KyInstance) => new MembersClient(h))
|
||||
const io = deps.io ?? nullStreams()
|
||||
const cs = colorScheme(colorEnabled(io.isErrTTY))
|
||||
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
bundle: deps.bundle,
|
||||
})
|
||||
|
||||
if (!opts.yes && io.isErrTTY) {
|
||||
const confirmed = await promptConfirm(io, `Remove member ${opts.memberId}? [y/N] `)
|
||||
if (!confirmed) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: 'aborted by user',
|
||||
hint: 'pass --yes to skip confirmation',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await runWithSpinner(
|
||||
{ io, label: `Removing ${opts.memberId}` },
|
||||
() => factory(deps.http).remove(wsId, opts.memberId),
|
||||
)
|
||||
|
||||
const textLine = `${cs.successIcon()} Removed ${opts.memberId}\n`
|
||||
return {
|
||||
data: new DeleteMemberOutput(opts.memberId, textLine),
|
||||
workspaceId: wsId,
|
||||
}
|
||||
}
|
||||
|
||||
async function promptConfirm(io: IOStreams, message: string): Promise<boolean> {
|
||||
io.err.write(message)
|
||||
const rl = readline.createInterface({ input: io.in, output: io.err, terminal: false })
|
||||
try {
|
||||
const line: string = await new Promise(resolve => rl.once('line', resolve))
|
||||
return line.trim().toLowerCase() === 'y'
|
||||
}
|
||||
finally {
|
||||
rl.close()
|
||||
}
|
||||
}
|
||||
89
cli/src/commands/get/member/handlers.ts
Normal file
89
cli/src/commands/get/member/handlers.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import type { MemberListResponse, MemberResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { TableCell } from '../../../framework/output.js'
|
||||
import type { TableColumn } from '../../../printers/format-table.js'
|
||||
|
||||
export const MEMBER_MODE_KEY = 'member'
|
||||
const CURRENT_MARKER = '*'
|
||||
|
||||
export const MEMBER_COLUMNS: readonly TableColumn[] = [
|
||||
{ name: 'ID', priority: 0 },
|
||||
{ name: 'NAME', priority: 0 },
|
||||
{ name: 'EMAIL', priority: 0 },
|
||||
{ name: 'ROLE', priority: 0 },
|
||||
{ name: 'STATUS', priority: 0 },
|
||||
{ name: 'CURRENT', priority: 0 },
|
||||
]
|
||||
|
||||
export class MemberRow {
|
||||
readonly id: string
|
||||
readonly displayName: string
|
||||
readonly email: string
|
||||
readonly role: string
|
||||
readonly status: string
|
||||
readonly current: boolean
|
||||
|
||||
constructor(member: MemberResponse, current: boolean) {
|
||||
this.id = member.id
|
||||
this.displayName = member.name
|
||||
this.email = member.email
|
||||
this.role = member.role
|
||||
this.status = member.status
|
||||
this.current = current
|
||||
}
|
||||
|
||||
tableRow(): readonly TableCell[] {
|
||||
return [
|
||||
this.id,
|
||||
this.displayName,
|
||||
this.email,
|
||||
this.role,
|
||||
this.status,
|
||||
this.current ? CURRENT_MARKER : '',
|
||||
]
|
||||
}
|
||||
|
||||
name(): string {
|
||||
return this.id
|
||||
}
|
||||
|
||||
json() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.displayName,
|
||||
email: this.email,
|
||||
role: this.role,
|
||||
status: this.status,
|
||||
current: this.current,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MemberListOutput {
|
||||
readonly rows: readonly MemberRow[]
|
||||
readonly envelope: MemberListResponse
|
||||
|
||||
constructor(rows: readonly MemberRow[], envelope: MemberListResponse) {
|
||||
this.rows = rows
|
||||
this.envelope = envelope
|
||||
}
|
||||
|
||||
static tableColumns(): readonly TableColumn[] {
|
||||
return MEMBER_COLUMNS
|
||||
}
|
||||
|
||||
tableColumns(): readonly TableColumn[] {
|
||||
return MemberListOutput.tableColumns()
|
||||
}
|
||||
|
||||
tableRows(): readonly (readonly TableCell[])[] {
|
||||
return this.rows.map(row => row.tableRow())
|
||||
}
|
||||
|
||||
name(): string {
|
||||
return this.rows.map(row => row.name()).join('\n')
|
||||
}
|
||||
|
||||
json(): MemberListResponse {
|
||||
return this.envelope
|
||||
}
|
||||
}
|
||||
44
cli/src/commands/get/member/index.ts
Normal file
44
cli/src/commands/get/member/index.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { table } from '../../../framework/output.js'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { httpRetryFlag } from '../../_shared/global-flags.js'
|
||||
import { runGetMember } from './run.js'
|
||||
|
||||
export default class GetMember extends DifyCommand {
|
||||
static override description = 'List members of the active (or specified) workspace'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> get member',
|
||||
'<%= config.bin %> get member -w ws-1',
|
||||
'<%= config.bin %> get member --page 2 --limit 50',
|
||||
'<%= config.bin %> get member -o json',
|
||||
'<%= config.bin %> get member -o name',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
'workspace': Flags.string({
|
||||
char: 'w',
|
||||
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
|
||||
}),
|
||||
'page': Flags.integer({ description: 'page number', default: 1 }),
|
||||
'limit': Flags.string({ description: 'page size [1..200]' }),
|
||||
'http-retry': httpRetryFlag,
|
||||
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|wide)', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]) {
|
||||
const { flags } = this.parse(GetMember, argv)
|
||||
const format = flags.output
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runGetMember(
|
||||
{
|
||||
workspace: flags.workspace,
|
||||
page: flags.page,
|
||||
limitRaw: flags.limit,
|
||||
format,
|
||||
},
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return table({ format, data: result.data })
|
||||
}
|
||||
}
|
||||
153
cli/src/commands/get/member/run.test.ts
Normal file
153
cli/src/commands/get/member/run.test.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import type { MemberListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../io/streams.js'
|
||||
import { runGetMember } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
}
|
||||
}
|
||||
|
||||
function fakeClient(envelope: MemberListResponse) {
|
||||
return { list: vi.fn(() => Promise.resolve(envelope)) }
|
||||
}
|
||||
|
||||
describe('runGetMember', () => {
|
||||
const env: MemberListResponse = {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 2,
|
||||
has_more: false,
|
||||
data: [
|
||||
{ id: 'acct-1', name: 'Me', email: 'me@example.com', role: 'owner', status: 'active' },
|
||||
{ id: 'acct-2', name: 'Mate', email: 'mate@example.com', role: 'admin', status: 'active' },
|
||||
],
|
||||
}
|
||||
|
||||
it('lists members and marks the calling account with current=true', async () => {
|
||||
const client = fakeClient(env)
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
)
|
||||
expect(client.list).toHaveBeenCalledExactlyOnceWith('ws-1', { page: 1, limit: 20 })
|
||||
expect(r.workspaceId).toBe('ws-1')
|
||||
expect(r.data.rows.map(row => row.current)).toEqual([true, false])
|
||||
expect(r.data.rows.map(row => row.id)).toEqual(['acct-1', 'acct-2'])
|
||||
})
|
||||
|
||||
it('-w flag overrides resolved workspace', async () => {
|
||||
const client = fakeClient(env)
|
||||
const r = await runGetMember(
|
||||
{ workspace: 'ws-9' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
)
|
||||
expect(client.list).toHaveBeenCalledWith('ws-9', { page: 1, limit: 20 })
|
||||
expect(r.workspaceId).toBe('ws-9')
|
||||
})
|
||||
|
||||
it('--page/--limit are forwarded to the client', async () => {
|
||||
const client = fakeClient(env)
|
||||
await runGetMember(
|
||||
{ page: 3, limitRaw: '50' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
)
|
||||
expect(client.list).toHaveBeenCalledWith('ws-1', { page: 3, limit: 50 })
|
||||
})
|
||||
|
||||
it('marks no row when bundle has no account id', async () => {
|
||||
const client = fakeClient(env)
|
||||
const b = bundle()
|
||||
b.account = { id: '', email: '', name: '' }
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
)
|
||||
expect(r.data.rows.every(row => !row.current)).toBe(true)
|
||||
})
|
||||
|
||||
it('throws when no workspace can be resolved', async () => {
|
||||
const client = fakeClient(env)
|
||||
await expect(
|
||||
runGetMember(
|
||||
{},
|
||||
{
|
||||
bundle: {
|
||||
current_host: '',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: '', name: '' },
|
||||
},
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
envLookup: () => undefined,
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/no workspace selected/)
|
||||
expect(client.list).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MemberListOutput shape', () => {
|
||||
it('builds table with CURRENT marker column', async () => {
|
||||
const env: MemberListResponse = {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 1,
|
||||
has_more: false,
|
||||
data: [
|
||||
{ id: 'acct-1', name: 'Me', email: 'me@example.com', role: 'owner', status: 'active' },
|
||||
],
|
||||
}
|
||||
const client = fakeClient(env)
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
)
|
||||
expect(r.data.tableColumns().map(c => c.name)).toEqual([
|
||||
'ID',
|
||||
'NAME',
|
||||
'EMAIL',
|
||||
'ROLE',
|
||||
'STATUS',
|
||||
'CURRENT',
|
||||
])
|
||||
expect(r.data.tableRows()[0]?.[5]).toBe('*')
|
||||
expect(r.data.name()).toBe('acct-1')
|
||||
expect(r.data.json().data[0]?.email).toBe('me@example.com')
|
||||
})
|
||||
})
|
||||
65
cli/src/commands/get/member/run.ts
Normal file
65
cli/src/commands/get/member/run.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../io/streams.js'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
import { runWithSpinner } from '../../../io/spinner.js'
|
||||
import { nullStreams } from '../../../io/streams.js'
|
||||
import { LIMIT_DEFAULT, parseLimit } from '../../../limit/limit.js'
|
||||
import { resolveWorkspaceId } from '../../../workspace/resolver.js'
|
||||
import { MemberListOutput, MemberRow } from './handlers.js'
|
||||
|
||||
export type GetMemberOptions = {
|
||||
readonly workspace?: string
|
||||
readonly page?: number
|
||||
readonly limitRaw?: string
|
||||
readonly format?: string
|
||||
}
|
||||
|
||||
export type GetMemberDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
readonly membersFactory?: (http: KyInstance) => MembersClient
|
||||
}
|
||||
|
||||
export type GetMemberResult = {
|
||||
readonly data: MemberListOutput
|
||||
readonly workspaceId: string
|
||||
}
|
||||
|
||||
export async function runGetMember(
|
||||
opts: GetMemberOptions,
|
||||
deps: GetMemberDeps,
|
||||
): Promise<GetMemberResult> {
|
||||
const env = deps.envLookup ?? ((k: string) => process.env[k])
|
||||
const factory = deps.membersFactory ?? ((h: KyInstance) => new MembersClient(h))
|
||||
const io = deps.io ?? nullStreams()
|
||||
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
bundle: deps.bundle,
|
||||
})
|
||||
|
||||
const limit = resolveLimit(opts.limitRaw, env)
|
||||
const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page
|
||||
|
||||
const envelope = await runWithSpinner(
|
||||
{ io, label: 'Fetching members' },
|
||||
() => factory(deps.http).list(wsId, { page, limit }),
|
||||
)
|
||||
|
||||
const callerId = deps.bundle.account?.id ?? ''
|
||||
const rows = envelope.data.map(m => new MemberRow(m, callerId !== '' && m.id === callerId))
|
||||
return { data: new MemberListOutput(rows, envelope), workspaceId: wsId }
|
||||
}
|
||||
|
||||
function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number {
|
||||
if (raw !== undefined && raw !== '')
|
||||
return parseLimit(raw, '--limit')
|
||||
const envValue = env('DIFY_LIMIT')
|
||||
if (envValue !== undefined && envValue !== '')
|
||||
return parseLimit(envValue, 'DIFY_LIMIT')
|
||||
return LIMIT_DEFAULT
|
||||
}
|
||||
26
cli/src/commands/set/member/handlers.ts
Normal file
26
cli/src/commands/set/member/handlers.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export type SetMemberPayload = {
|
||||
readonly id: string
|
||||
readonly role: 'normal' | 'admin'
|
||||
}
|
||||
|
||||
export class SetMemberOutput {
|
||||
readonly payload: SetMemberPayload
|
||||
readonly textLine: string
|
||||
|
||||
constructor(payload: SetMemberPayload, textLine: string) {
|
||||
this.payload = payload
|
||||
this.textLine = textLine
|
||||
}
|
||||
|
||||
text(): string {
|
||||
return this.textLine
|
||||
}
|
||||
|
||||
json(): SetMemberPayload {
|
||||
return this.payload
|
||||
}
|
||||
|
||||
name(): string {
|
||||
return this.payload.id
|
||||
}
|
||||
}
|
||||
43
cli/src/commands/set/member/index.ts
Normal file
43
cli/src/commands/set/member/index.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Args, Flags } from '../../../framework/flags.js'
|
||||
import { formatted } from '../../../framework/output.js'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { httpRetryFlag } from '../../_shared/global-flags.js'
|
||||
import { runSetMember } from './run.js'
|
||||
|
||||
export default class SetMember extends DifyCommand {
|
||||
static override description = 'Change a member\'s role in the active (or specified) workspace'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> set member acct-1 --role admin',
|
||||
'<%= config.bin %> set member acct-1 --role normal -w ws-1',
|
||||
'<%= config.bin %> set member acct-1 --role admin -o json',
|
||||
]
|
||||
|
||||
static override args = {
|
||||
memberId: Args.string({ description: 'account id of the member to update', required: true }),
|
||||
}
|
||||
|
||||
static override flags = {
|
||||
'role': Flags.string({
|
||||
description: 'new role (normal|admin); owner is not assignable here',
|
||||
required: true,
|
||||
}),
|
||||
'workspace': Flags.string({
|
||||
char: 'w',
|
||||
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
|
||||
}),
|
||||
'http-retry': httpRetryFlag,
|
||||
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|text)', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]) {
|
||||
const { args, flags } = this.parse(SetMember, argv)
|
||||
const format = flags.output
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runSetMember(
|
||||
{ memberId: args.memberId, role: flags.role, workspace: flags.workspace, format },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
}
|
||||
87
cli/src/commands/set/member/run.test.ts
Normal file
87
cli/src/commands/set/member/run.test.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../io/streams.js'
|
||||
import { runSetMember } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
}
|
||||
}
|
||||
|
||||
function fakeClient() {
|
||||
return {
|
||||
updateRole: vi.fn(() => Promise.resolve({ result: 'success' as const })),
|
||||
}
|
||||
}
|
||||
|
||||
describe('runSetMember', () => {
|
||||
it('happy path: PUT new role, returns SetMemberOutput with text/json/name', async () => {
|
||||
const client = fakeClient()
|
||||
const result = await runSetMember(
|
||||
{ memberId: 'acct-2', role: 'admin' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
)
|
||||
expect(client.updateRole).toHaveBeenCalledExactlyOnceWith('ws-1', 'acct-2', { role: 'admin' })
|
||||
expect(result.data.text()).toMatch(/Set acct-2 role to admin/)
|
||||
expect(result.data.name()).toBe('acct-2')
|
||||
expect(result.data.json()).toEqual({ id: 'acct-2', role: 'admin' })
|
||||
expect(result.workspaceId).toBe('ws-1')
|
||||
})
|
||||
|
||||
it('rejects unknown role before any HTTP call', async () => {
|
||||
const client = fakeClient()
|
||||
await expect(
|
||||
runSetMember(
|
||||
{ memberId: 'acct-2', role: 'owner' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/invalid --role/)
|
||||
expect(client.updateRole).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects empty member id', async () => {
|
||||
const client = fakeClient()
|
||||
await expect(
|
||||
runSetMember(
|
||||
{ memberId: '', role: 'admin' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/member id is required/)
|
||||
})
|
||||
|
||||
it('-w flag overrides resolved workspace', async () => {
|
||||
const client = fakeClient()
|
||||
await runSetMember(
|
||||
{ memberId: 'acct-2', role: 'normal', workspace: 'ws-9' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
},
|
||||
)
|
||||
expect(client.updateRole).toHaveBeenCalledWith('ws-9', 'acct-2', { role: 'normal' })
|
||||
})
|
||||
})
|
||||
78
cli/src/commands/set/member/run.ts
Normal file
78
cli/src/commands/set/member/run.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../io/streams.js'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from '../../../io/color.js'
|
||||
import { runWithSpinner } from '../../../io/spinner.js'
|
||||
import { nullStreams } from '../../../io/streams.js'
|
||||
import { resolveWorkspaceId } from '../../../workspace/resolver.js'
|
||||
import { SetMemberOutput } from './handlers.js'
|
||||
|
||||
export type SetMemberOptions = {
|
||||
readonly memberId: string
|
||||
readonly role: string
|
||||
readonly workspace?: string
|
||||
readonly format?: string
|
||||
}
|
||||
|
||||
export type SetMemberDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
readonly membersFactory?: (http: KyInstance) => MembersClient
|
||||
}
|
||||
|
||||
export type SetMemberResult = {
|
||||
readonly data: SetMemberOutput
|
||||
readonly workspaceId: string
|
||||
}
|
||||
|
||||
const ASSIGNABLE_ROLES = new Set(['normal', 'admin'])
|
||||
|
||||
export async function runSetMember(
|
||||
opts: SetMemberOptions,
|
||||
deps: SetMemberDeps,
|
||||
): Promise<SetMemberResult> {
|
||||
if (opts.memberId === undefined || opts.memberId === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: 'member id is required',
|
||||
hint: 'pass it positionally: difyctl set member <member-id> --role <role>',
|
||||
})
|
||||
}
|
||||
if (!ASSIGNABLE_ROLES.has(opts.role)) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `invalid --role "${opts.role}"`,
|
||||
hint: 'expected: normal | admin (ownership transfer is console-only)',
|
||||
})
|
||||
}
|
||||
|
||||
const env = deps.envLookup ?? ((k: string) => process.env[k])
|
||||
const factory = deps.membersFactory ?? ((h: KyInstance) => new MembersClient(h))
|
||||
const io = deps.io ?? nullStreams()
|
||||
const cs = colorScheme(colorEnabled(io.isErrTTY))
|
||||
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
bundle: deps.bundle,
|
||||
})
|
||||
|
||||
await runWithSpinner(
|
||||
{ io, label: `Updating role for ${opts.memberId}` },
|
||||
() => factory(deps.http).updateRole(wsId, opts.memberId, {
|
||||
role: opts.role as 'normal' | 'admin',
|
||||
}),
|
||||
)
|
||||
|
||||
const role = opts.role as 'normal' | 'admin'
|
||||
const textLine = `${cs.successIcon()} Set ${opts.memberId} role to ${role}\n`
|
||||
return {
|
||||
data: new SetMemberOutput({ id: opts.memberId, role }, textLine),
|
||||
workspaceId: wsId,
|
||||
}
|
||||
}
|
||||
@ -7,22 +7,26 @@ import AuthDevicesRevoke from './auth/devices/revoke/index.js'
|
||||
import AuthLogin from './auth/login/index.js'
|
||||
import AuthLogout from './auth/logout/index.js'
|
||||
import AuthStatus from './auth/status/index.js'
|
||||
import AuthUse from './auth/use/index.js'
|
||||
import AuthWhoami from './auth/whoami/index.js'
|
||||
import ConfigGet from './config/get/index.js'
|
||||
import ConfigPath from './config/path/index.js'
|
||||
import ConfigSet from './config/set/index.js'
|
||||
import ConfigUnset from './config/unset/index.js'
|
||||
import ConfigView from './config/view/index.js'
|
||||
import CreateMember from './create/member/index.js'
|
||||
import DeleteMember from './delete/member/index.js'
|
||||
import DescribeApp from './describe/app/index.js'
|
||||
import EnvList from './env/list/index.js'
|
||||
import GetApp from './get/app/index.js'
|
||||
import GetMember from './get/member/index.js'
|
||||
import GetWorkspace from './get/workspace/index.js'
|
||||
import HelpAccount from './help/account/index.js'
|
||||
import HelpEnvironment from './help/environment/index.js'
|
||||
import HelpExternal from './help/external/index.js'
|
||||
import ResumeApp from './resume/app/index.js'
|
||||
import RunApp from './run/app/index.js'
|
||||
import SetMember from './set/member/index.js'
|
||||
import UseWorkspace from './use/workspace/index.js'
|
||||
import Version from './version/index.js'
|
||||
|
||||
export const commandTree: CommandTree = {
|
||||
@ -37,7 +41,6 @@ export const commandTree: CommandTree = {
|
||||
login: { command: AuthLogin, subcommands: {} },
|
||||
logout: { command: AuthLogout, subcommands: {} },
|
||||
status: { command: AuthStatus, subcommands: {} },
|
||||
use: { command: AuthUse, subcommands: {} },
|
||||
whoami: { command: AuthWhoami, subcommands: {} },
|
||||
},
|
||||
},
|
||||
@ -50,6 +53,16 @@ export const commandTree: CommandTree = {
|
||||
view: { command: ConfigView, subcommands: {} },
|
||||
},
|
||||
},
|
||||
create: {
|
||||
subcommands: {
|
||||
member: { command: CreateMember, subcommands: {} },
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
subcommands: {
|
||||
member: { command: DeleteMember, subcommands: {} },
|
||||
},
|
||||
},
|
||||
describe: {
|
||||
subcommands: {
|
||||
app: { command: DescribeApp, subcommands: {} },
|
||||
@ -63,6 +76,7 @@ export const commandTree: CommandTree = {
|
||||
get: {
|
||||
subcommands: {
|
||||
app: { command: GetApp, subcommands: {} },
|
||||
member: { command: GetMember, subcommands: {} },
|
||||
workspace: { command: GetWorkspace, subcommands: {} },
|
||||
},
|
||||
},
|
||||
@ -83,5 +97,15 @@ export const commandTree: CommandTree = {
|
||||
app: { command: RunApp, subcommands: {} },
|
||||
},
|
||||
},
|
||||
set: {
|
||||
subcommands: {
|
||||
member: { command: SetMember, subcommands: {} },
|
||||
},
|
||||
},
|
||||
use: {
|
||||
subcommands: {
|
||||
workspace: { command: UseWorkspace, subcommands: {} },
|
||||
},
|
||||
},
|
||||
version: { command: Version, subcommands: {} },
|
||||
}
|
||||
|
||||
31
cli/src/commands/use/workspace/index.ts
Normal file
31
cli/src/commands/use/workspace/index.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Args } from '../../../framework/flags.js'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { httpRetryFlag } from '../../_shared/global-flags.js'
|
||||
import { runUseWorkspace } from './use.js'
|
||||
|
||||
export default class UseWorkspace extends DifyCommand {
|
||||
static override description = 'Switch the active workspace on the server and refresh hosts.yml'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> use workspace ws-abc123',
|
||||
]
|
||||
|
||||
static override args = {
|
||||
workspaceId: Args.string({ description: 'workspace id to switch to', required: true }),
|
||||
}
|
||||
|
||||
static override flags = {
|
||||
'http-retry': httpRetryFlag,
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { args, flags } = this.parse(UseWorkspace, argv)
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
|
||||
await runUseWorkspace({ workspaceId: args.workspaceId }, {
|
||||
configDir: ctx.configDir,
|
||||
bundle: ctx.bundle,
|
||||
http: ctx.http,
|
||||
io: ctx.io,
|
||||
})
|
||||
}
|
||||
}
|
||||
199
cli/src/commands/use/workspace/use.test.ts
Normal file
199
cli/src/commands/use/workspace/use.test.ts
Normal file
@ -0,0 +1,199 @@
|
||||
import type {
|
||||
WorkspaceDetailResponse,
|
||||
WorkspaceListResponse,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadHosts, saveHosts } from '../../../auth/hosts.js'
|
||||
import { bufferStreams } from '../../../io/streams.js'
|
||||
import { runUseWorkspace } from './use.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Stale Name', role: 'normal' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function fakeClient(opts: {
|
||||
switch?: () => Promise<WorkspaceDetailResponse>
|
||||
list?: () => Promise<WorkspaceListResponse>
|
||||
}) {
|
||||
return {
|
||||
switch: vi.fn(opts.switch ?? (() => Promise.resolve({
|
||||
id: 'ws-2',
|
||||
name: 'Switched',
|
||||
role: 'normal',
|
||||
status: 'normal',
|
||||
current: true,
|
||||
created_at: '2026-05-18T00:00:00Z',
|
||||
}))),
|
||||
list: vi.fn(opts.list ?? (() => Promise.resolve({
|
||||
workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: false },
|
||||
{ id: 'ws-2', name: 'Switched', role: 'normal', status: 'normal', current: true },
|
||||
],
|
||||
}))),
|
||||
}
|
||||
}
|
||||
|
||||
describe('runUseWorkspace', () => {
|
||||
let configDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
configDir = await mkdtemp(join(tmpdir(), 'difyctl-use-workspace-'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('happy path: POST /switch → GET /workspaces → write hosts.yml', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
await saveHosts(configDir, b)
|
||||
const client = fakeClient({})
|
||||
|
||||
const next = await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
configDir,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
},
|
||||
)
|
||||
|
||||
expect(client.switch).toHaveBeenCalledExactlyOnceWith('ws-2')
|
||||
expect(client.list).toHaveBeenCalledOnce()
|
||||
expect(next.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
|
||||
expect(next.available_workspaces).toEqual([
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Switched', role: 'normal' },
|
||||
])
|
||||
const reloaded = await loadHosts(configDir)
|
||||
expect(reloaded?.workspace?.id).toBe('ws-2')
|
||||
expect(reloaded?.workspace?.name).toBe('Switched')
|
||||
expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/)
|
||||
})
|
||||
|
||||
it('refreshes stale workspace name from server', async () => {
|
||||
// bundle has ws-2 named "Stale Name"; server returns "Switched".
|
||||
// We expect saveHosts to record the fresh name from the server.
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
await saveHosts(configDir, b)
|
||||
const client = fakeClient({})
|
||||
|
||||
await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{ configDir, bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
)
|
||||
|
||||
const reloaded = await loadHosts(configDir)
|
||||
expect(reloaded?.workspace?.name).toBe('Switched')
|
||||
expect(reloaded?.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
|
||||
})
|
||||
|
||||
it('does NOT mutate hosts.yml when POST /switch fails', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
await saveHosts(configDir, b)
|
||||
const before = await loadHosts(configDir)
|
||||
|
||||
const client = fakeClient({
|
||||
switch: () => Promise.reject(new Error('forbidden')),
|
||||
})
|
||||
|
||||
await expect(
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
configDir,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/forbidden/)
|
||||
|
||||
expect(client.list).not.toHaveBeenCalled()
|
||||
const after = await loadHosts(configDir)
|
||||
expect(after).toEqual(before)
|
||||
expect(after?.workspace?.id).toBe('ws-1')
|
||||
})
|
||||
|
||||
it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
await saveHosts(configDir, b)
|
||||
const before = await loadHosts(configDir)
|
||||
|
||||
const client = fakeClient({
|
||||
list: () => Promise.reject(new Error('transient list failure')),
|
||||
})
|
||||
|
||||
await expect(
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
configDir,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/transient list failure/)
|
||||
|
||||
const after = await loadHosts(configDir)
|
||||
expect(after).toEqual(before)
|
||||
})
|
||||
|
||||
it('throws when server returns switch=<id> but id is missing from /workspaces list', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
await saveHosts(configDir, b)
|
||||
|
||||
const client = fakeClient({
|
||||
switch: () => Promise.resolve({
|
||||
id: 'ws-7',
|
||||
name: 'Ghost',
|
||||
role: 'normal',
|
||||
status: 'normal',
|
||||
current: true,
|
||||
created_at: null as unknown as string,
|
||||
}),
|
||||
list: () => Promise.resolve({
|
||||
workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: false },
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
await expect(
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-7' },
|
||||
{
|
||||
configDir,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/not visible in \/workspaces/)
|
||||
})
|
||||
})
|
||||
76
cli/src/commands/use/workspace/use.ts
Normal file
76
cli/src/commands/use/workspace/use.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../io/streams.js'
|
||||
import { WorkspacesClient } from '../../../api/workspaces.js'
|
||||
import { saveHosts } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from '../../../io/color.js'
|
||||
import { runWithSpinner } from '../../../io/spinner.js'
|
||||
|
||||
export type UseWorkspaceOptions = {
|
||||
readonly workspaceId: string
|
||||
}
|
||||
|
||||
export type UseWorkspaceDeps = {
|
||||
readonly configDir: string
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io: IOStreams
|
||||
readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the caller's active workspace.
|
||||
*
|
||||
* Strict ordering:
|
||||
* 1. POST /workspaces/<id>/switch — if this fails (403/404/etc.) we abort
|
||||
* with no `hosts.yml` mutation, so local state never diverges from the
|
||||
* server. Any fallback to a pure-local update is explicitly disallowed
|
||||
* (see workspace-plan.md decision D4).
|
||||
* 2. GET /workspaces — refresh the membership list so `available_workspaces`
|
||||
* stays in sync. Failure here also aborts; the server-side current has
|
||||
* already moved, but the local file is left untouched. A follow-up
|
||||
* `difyctl get workspace` will reconcile.
|
||||
* 3. Persist `workspace` + `available_workspaces` atomically via `saveHosts`.
|
||||
*/
|
||||
export async function runUseWorkspace(
|
||||
opts: UseWorkspaceOptions,
|
||||
deps: UseWorkspaceDeps,
|
||||
): Promise<HostsBundle> {
|
||||
const cs = colorScheme(colorEnabled(deps.io.isErrTTY))
|
||||
const factory = deps.workspacesFactory ?? ((h: KyInstance) => new WorkspacesClient(h))
|
||||
const client = factory(deps.http)
|
||||
|
||||
const detail = await runWithSpinner(
|
||||
{ io: deps.io, label: `Switching to ${opts.workspaceId}` },
|
||||
() => client.switch(opts.workspaceId),
|
||||
)
|
||||
|
||||
const list = await runWithSpinner(
|
||||
{ io: deps.io, label: 'Refreshing workspaces' },
|
||||
() => client.list(),
|
||||
)
|
||||
|
||||
const matched = list.workspaces.find(w => w.id === detail.id)
|
||||
if (matched === undefined) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Unknown,
|
||||
message: `server returned switch=${detail.id} but it is not visible in /workspaces`,
|
||||
hint: 'try again or contact your workspace admin',
|
||||
})
|
||||
}
|
||||
|
||||
const next: HostsBundle = {
|
||||
...deps.bundle,
|
||||
workspace: { id: matched.id, name: matched.name, role: matched.role },
|
||||
available_workspaces: list.workspaces.map<Workspace>(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
role: w.role,
|
||||
})),
|
||||
}
|
||||
await saveHosts(deps.configDir, next)
|
||||
deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`)
|
||||
return next
|
||||
}
|
||||
@ -25,7 +25,7 @@ export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: 'no workspace selected',
|
||||
hint: 'pass --workspace, set DIFY_WORKSPACE_ID, or run \'difyctl auth use\'',
|
||||
hint: 'pass --workspace, set DIFY_WORKSPACE_ID, or run \'difyctl use workspace <id>\'',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,8 @@ import {
|
||||
zDeleteAccountSessionsBySessionIdPath,
|
||||
zDeleteAccountSessionsBySessionIdResponse,
|
||||
zDeleteAccountSessionsSelfResponse,
|
||||
zDeleteWorkspacesByWorkspaceIdMembersByMemberIdPath,
|
||||
zDeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse,
|
||||
zGetAccountResponse,
|
||||
zGetAccountSessionsResponse,
|
||||
zGetAppsByAppIdDescribePath,
|
||||
@ -23,6 +25,9 @@ import {
|
||||
zGetOauthDeviceLookupResponse,
|
||||
zGetPermittedExternalAppsResponse,
|
||||
zGetVersionResponse,
|
||||
zGetWorkspacesByWorkspaceIdMembersPath,
|
||||
zGetWorkspacesByWorkspaceIdMembersQuery,
|
||||
zGetWorkspacesByWorkspaceIdMembersResponse,
|
||||
zGetWorkspacesByWorkspaceIdPath,
|
||||
zGetWorkspacesByWorkspaceIdResponse,
|
||||
zGetWorkspacesResponse,
|
||||
@ -44,6 +49,14 @@ import {
|
||||
zPostOauthDeviceDenyResponse,
|
||||
zPostOauthDeviceTokenBody,
|
||||
zPostOauthDeviceTokenResponse,
|
||||
zPostWorkspacesByWorkspaceIdMembersBody,
|
||||
zPostWorkspacesByWorkspaceIdMembersPath,
|
||||
zPostWorkspacesByWorkspaceIdMembersResponse,
|
||||
zPostWorkspacesByWorkspaceIdSwitchPath,
|
||||
zPostWorkspacesByWorkspaceIdSwitchResponse,
|
||||
zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleBody,
|
||||
zPutWorkspacesByWorkspaceIdMembersByMemberIdRolePath,
|
||||
zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse,
|
||||
} from './zod.gen'
|
||||
|
||||
/**
|
||||
@ -461,7 +474,97 @@ export const permittedExternalApps = {
|
||||
get: get10,
|
||||
}
|
||||
|
||||
export const put = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'PUT',
|
||||
operationId: 'putWorkspacesByWorkspaceIdMembersByMemberIdRole',
|
||||
path: '/workspaces/{workspace_id}/members/{member_id}/role',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleBody,
|
||||
params: zPutWorkspacesByWorkspaceIdMembersByMemberIdRolePath,
|
||||
}),
|
||||
)
|
||||
.output(zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse)
|
||||
|
||||
export const role = {
|
||||
put,
|
||||
}
|
||||
|
||||
export const delete3 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'DELETE',
|
||||
operationId: 'deleteWorkspacesByWorkspaceIdMembersByMemberId',
|
||||
path: '/workspaces/{workspace_id}/members/{member_id}',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zDeleteWorkspacesByWorkspaceIdMembersByMemberIdPath }))
|
||||
.output(zDeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse)
|
||||
|
||||
export const byMemberId = {
|
||||
delete: delete3,
|
||||
role,
|
||||
}
|
||||
|
||||
export const get11 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getWorkspacesByWorkspaceIdMembers',
|
||||
path: '/workspaces/{workspace_id}/members',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
params: zGetWorkspacesByWorkspaceIdMembersPath,
|
||||
query: zGetWorkspacesByWorkspaceIdMembersQuery.optional(),
|
||||
}),
|
||||
)
|
||||
.output(zGetWorkspacesByWorkspaceIdMembersResponse)
|
||||
|
||||
export const post9 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postWorkspacesByWorkspaceIdMembers',
|
||||
path: '/workspaces/{workspace_id}/members',
|
||||
successStatus: 201,
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPostWorkspacesByWorkspaceIdMembersBody,
|
||||
params: zPostWorkspacesByWorkspaceIdMembersPath,
|
||||
}),
|
||||
)
|
||||
.output(zPostWorkspacesByWorkspaceIdMembersResponse)
|
||||
|
||||
export const members = {
|
||||
get: get11,
|
||||
post: post9,
|
||||
byMemberId,
|
||||
}
|
||||
|
||||
export const post10 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postWorkspacesByWorkspaceIdSwitch',
|
||||
path: '/workspaces/{workspace_id}/switch',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zPostWorkspacesByWorkspaceIdSwitchPath }))
|
||||
.output(zPostWorkspacesByWorkspaceIdSwitchResponse)
|
||||
|
||||
export const switch_ = {
|
||||
post: post10,
|
||||
}
|
||||
|
||||
export const get12 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@ -473,10 +576,12 @@ export const get11 = oc
|
||||
.output(zGetWorkspacesByWorkspaceIdResponse)
|
||||
|
||||
export const byWorkspaceId = {
|
||||
get: get11,
|
||||
get: get12,
|
||||
members,
|
||||
switch: switch_,
|
||||
}
|
||||
|
||||
export const get12 = oc
|
||||
export const get13 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@ -487,7 +592,7 @@ export const get12 = oc
|
||||
.output(zGetWorkspacesResponse)
|
||||
|
||||
export const workspaces = {
|
||||
get: get12,
|
||||
get: get13,
|
||||
byWorkspaceId,
|
||||
}
|
||||
|
||||
|
||||
@ -169,6 +169,50 @@ export type HumanInputFormSubmitPayload = {
|
||||
|
||||
export type JsonValue = unknown
|
||||
|
||||
export type MemberActionResponse = {
|
||||
result?: string
|
||||
}
|
||||
|
||||
export type MemberInvitePayload = {
|
||||
email: string
|
||||
role: 'admin' | 'normal'
|
||||
}
|
||||
|
||||
export type MemberInviteResponse = {
|
||||
email: string
|
||||
invite_url: string
|
||||
member_id: string
|
||||
result?: string
|
||||
role: string
|
||||
tenant_id: string
|
||||
}
|
||||
|
||||
export type MemberListQuery = {
|
||||
limit?: number
|
||||
page?: number
|
||||
}
|
||||
|
||||
export type MemberListResponse = {
|
||||
data: Array<MemberResponse>
|
||||
has_more: boolean
|
||||
limit: number
|
||||
page: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type MemberResponse = {
|
||||
avatar?: string | null
|
||||
email: string
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export type MemberRoleUpdatePayload = {
|
||||
role: 'admin' | 'normal'
|
||||
}
|
||||
|
||||
export type MessageMetadata = {
|
||||
retriever_resources?: Array<{
|
||||
[key: string]: unknown
|
||||
@ -638,3 +682,88 @@ export type GetWorkspacesByWorkspaceIdResponses = {
|
||||
|
||||
export type GetWorkspacesByWorkspaceIdResponse
|
||||
= GetWorkspacesByWorkspaceIdResponses[keyof GetWorkspacesByWorkspaceIdResponses]
|
||||
|
||||
export type GetWorkspacesByWorkspaceIdMembersData = {
|
||||
body?: never
|
||||
path: {
|
||||
workspace_id: string
|
||||
}
|
||||
query?: {
|
||||
limit?: number
|
||||
page?: number
|
||||
}
|
||||
url: '/workspaces/{workspace_id}/members'
|
||||
}
|
||||
|
||||
export type GetWorkspacesByWorkspaceIdMembersResponses = {
|
||||
200: MemberListResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesByWorkspaceIdMembersResponse
|
||||
= GetWorkspacesByWorkspaceIdMembersResponses[keyof GetWorkspacesByWorkspaceIdMembersResponses]
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdMembersData = {
|
||||
body: MemberInvitePayload
|
||||
path: {
|
||||
workspace_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/{workspace_id}/members'
|
||||
}
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdMembersResponses = {
|
||||
201: MemberInviteResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdMembersResponse
|
||||
= PostWorkspacesByWorkspaceIdMembersResponses[keyof PostWorkspacesByWorkspaceIdMembersResponses]
|
||||
|
||||
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
member_id: string
|
||||
workspace_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/{workspace_id}/members/{member_id}'
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses = {
|
||||
200: MemberActionResponse
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse
|
||||
= DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses[keyof DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses]
|
||||
|
||||
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleData = {
|
||||
body: MemberRoleUpdatePayload
|
||||
path: {
|
||||
member_id: string
|
||||
workspace_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/{workspace_id}/members/{member_id}/role'
|
||||
}
|
||||
|
||||
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses = {
|
||||
200: MemberActionResponse
|
||||
}
|
||||
|
||||
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse
|
||||
= PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses[keyof PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses]
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdSwitchData = {
|
||||
body?: never
|
||||
path: {
|
||||
workspace_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/{workspace_id}/switch'
|
||||
}
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdSwitchResponses = {
|
||||
200: WorkspaceDetailResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdSwitchResponse
|
||||
= PostWorkspacesByWorkspaceIdSwitchResponses[keyof PostWorkspacesByWorkspaceIdSwitchResponses]
|
||||
|
||||
@ -150,6 +150,73 @@ export const zHumanInputFormSubmitPayload = z.object({
|
||||
inputs: z.record(z.string(), zJsonValue),
|
||||
})
|
||||
|
||||
/**
|
||||
* MemberActionResponse
|
||||
*/
|
||||
export const zMemberActionResponse = z.object({
|
||||
result: z.string().optional().default('success'),
|
||||
})
|
||||
|
||||
/**
|
||||
* MemberInvitePayload
|
||||
*/
|
||||
export const zMemberInvitePayload = z.object({
|
||||
email: z.string(),
|
||||
role: z.enum(['admin', 'normal']),
|
||||
})
|
||||
|
||||
/**
|
||||
* MemberInviteResponse
|
||||
*/
|
||||
export const zMemberInviteResponse = z.object({
|
||||
email: z.string(),
|
||||
invite_url: z.string(),
|
||||
member_id: z.string(),
|
||||
result: z.string().optional().default('success'),
|
||||
role: z.string(),
|
||||
tenant_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* MemberListQuery
|
||||
*
|
||||
* Strict (extra='forbid').
|
||||
*/
|
||||
export const zMemberListQuery = z.object({
|
||||
limit: z.int().gte(1).lte(200).optional().default(20),
|
||||
page: z.int().gte(1).optional().default(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* MemberResponse
|
||||
*/
|
||||
export const zMemberResponse = z.object({
|
||||
avatar: z.string().nullish(),
|
||||
email: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
role: z.string(),
|
||||
status: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* MemberListResponse
|
||||
*/
|
||||
export const zMemberListResponse = z.object({
|
||||
data: z.array(zMemberResponse),
|
||||
has_more: z.boolean(),
|
||||
limit: z.int(),
|
||||
page: z.int(),
|
||||
total: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* MemberRoleUpdatePayload
|
||||
*/
|
||||
export const zMemberRoleUpdatePayload = z.object({
|
||||
role: z.enum(['admin', 'normal']),
|
||||
})
|
||||
|
||||
/**
|
||||
* PermittedExternalAppsListQuery
|
||||
*
|
||||
@ -546,3 +613,59 @@ export const zGetWorkspacesByWorkspaceIdPath = z.object({
|
||||
* Workspace detail
|
||||
*/
|
||||
export const zGetWorkspacesByWorkspaceIdResponse = zWorkspaceDetailResponse
|
||||
|
||||
export const zGetWorkspacesByWorkspaceIdMembersPath = z.object({
|
||||
workspace_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetWorkspacesByWorkspaceIdMembersQuery = z.object({
|
||||
limit: z.int().gte(1).lte(200).optional().default(20),
|
||||
page: z.int().gte(1).optional().default(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* Member list
|
||||
*/
|
||||
export const zGetWorkspacesByWorkspaceIdMembersResponse = zMemberListResponse
|
||||
|
||||
export const zPostWorkspacesByWorkspaceIdMembersBody = zMemberInvitePayload
|
||||
|
||||
export const zPostWorkspacesByWorkspaceIdMembersPath = z.object({
|
||||
workspace_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Member invited
|
||||
*/
|
||||
export const zPostWorkspacesByWorkspaceIdMembersResponse = zMemberInviteResponse
|
||||
|
||||
export const zDeleteWorkspacesByWorkspaceIdMembersByMemberIdPath = z.object({
|
||||
member_id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Member removed
|
||||
*/
|
||||
export const zDeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse = zMemberActionResponse
|
||||
|
||||
export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleBody = zMemberRoleUpdatePayload
|
||||
|
||||
export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRolePath = z.object({
|
||||
member_id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Role updated
|
||||
*/
|
||||
export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse = zMemberActionResponse
|
||||
|
||||
export const zPostWorkspacesByWorkspaceIdSwitchPath = z.object({
|
||||
workspace_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Workspace detail
|
||||
*/
|
||||
export const zPostWorkspacesByWorkspaceIdSwitchResponse = zWorkspaceDetailResponse
|
||||
|
||||
Loading…
Reference in New Issue
Block a user