diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index 998829098a..f89ef0111c 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -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, diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index 128a937549..59b2e5176e 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -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" diff --git a/api/controllers/openapi/auth/role_gate.py b/api/controllers/openapi/auth/role_gate.py new file mode 100644 index 0000000000..c5e266f63d --- /dev/null +++ b/api/controllers/openapi/auth/role_gate.py @@ -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//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/`) +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 diff --git a/api/controllers/openapi/workspaces.py b/api/controllers/openapi/workspaces.py index 5fc1e1178d..fa2aca7dd0 100644 --- a/api/controllers/openapi/workspaces.py +++ b/api/controllers/openapi/workspaces.py @@ -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//switch") +class WorkspaceSwitchApi(Resource): + """Server-side switch — equivalent to the console's POST /workspaces/switch. + + CLI `difyctl use workspace ` 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//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//members/") +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//members//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), diff --git a/api/openapi/markdown/openapi-swagger.md b/api/openapi/markdown/openapi-swagger.md index 419acdca24..899e09ff4a 100644 --- a/api/openapi/markdown/openapi-swagger.md +++ b/api/openapi/markdown/openapi-swagger.md @@ -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 | diff --git a/api/services/account_service.py b/api/services/account_service.py index 344b3619f2..6705bdc4e6 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -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. diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_role_gate.py b/api/tests/unit_tests/controllers/openapi/auth/test_role_gate.py new file mode 100644 index 0000000000..9befc7dad3 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_role_gate.py @@ -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/`; 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() diff --git a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py new file mode 100644 index 0000000000..970b5661e5 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py @@ -0,0 +1,918 @@ +"""Member endpoints under /openapi/v1/workspaces//... + +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//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//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//members/") + 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//members//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) diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index 5e89d9fb42..e09102a788 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -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( diff --git a/cli/ARD.md b/cli/ARD.md index b8813fe920..de7a4b359f 100644 --- a/cli/ARD.md +++ b/cli/ARD.md @@ -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 \'', + hint: 'pass --workspace or run \'difyctl use workspace \'', }) ``` diff --git a/cli/src/api/account-sessions.ts b/cli/src/api/account-sessions.ts index 102927bf8e..83950c9bde 100644 --- a/cli/src/api/account-sessions.ts +++ b/cli/src/api/account-sessions.ts @@ -8,8 +8,15 @@ export class AccountSessionsClient { this.http = http } - async list(): Promise { - return this.http.get('account/sessions').json() + async list(q?: { page?: number, limit?: number }): Promise { + 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() } async revoke(sessionId: string): Promise { diff --git a/cli/src/api/members.test.ts b/cli/src/api/members.test.ts new file mode 100644 index 0000000000..5442e296c3 --- /dev/null +++ b/cli/src/api/members.test.ts @@ -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 +} + +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 { + 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((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//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//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, + ) + }) +}) diff --git a/cli/src/api/members.ts b/cli/src/api/members.ts new file mode 100644 index 0000000000..1152ae1474 --- /dev/null +++ b/cli/src/api/members.ts @@ -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//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 { + 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() + } + + async invite(workspaceId: string, payload: MemberInvitePayload): Promise { + return this.http + .post(`workspaces/${encodeURIComponent(workspaceId)}/members`, { json: payload }) + .json() + } + + async remove(workspaceId: string, memberId: string): Promise { + return this.http + .delete(`workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}`) + .json() + } + + async updateRole( + workspaceId: string, + memberId: string, + payload: MemberRoleUpdatePayload, + ): Promise { + return this.http + .put( + `workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}/role`, + { json: payload }, + ) + .json() + } +} diff --git a/cli/src/api/workspaces.ts b/cli/src/api/workspaces.ts index a3feac23d0..f497ae25db 100644 --- a/cli/src/api/workspaces.ts +++ b/cli/src/api/workspaces.ts @@ -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 { return this.http.get('workspaces').json() } + + /** + * 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 { + return this.http + .post(`workspaces/${encodeURIComponent(workspaceId)}/switch`) + .json() + } } diff --git a/cli/src/commands/auth/devices/_shared/devices.test.ts b/cli/src/commands/auth/devices/_shared/devices.test.ts index 92e9bc8826..c7968268e6 100644 --- a/cli/src/commands/auth/devices/_shared/devices.test.ts +++ b/cli/src/commands/auth/devices/_shared/devices.test.ts @@ -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() @@ -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 } { + 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) + }) +}) diff --git a/cli/src/commands/auth/devices/_shared/devices.ts b/cli/src/commands/auth/devices/_shared/devices.ts index 5af41cd4db..de2407e388 100644 --- a/cli/src/commands/auth/devices/_shared/devices.ts +++ b/cli/src/commands/auth/devices/_shared/devices.ts @@ -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 { 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 { + 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 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 { 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, + }) } } diff --git a/cli/src/commands/auth/use/index.ts b/cli/src/commands/auth/use/index.ts deleted file mode 100644 index 5803e6450e..0000000000 --- a/cli/src/commands/auth/use/index.ts +++ /dev/null @@ -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 { - const { args } = this.parse(Use, argv) - const configDir = resolveConfigDir() - const bundle = await loadHosts(configDir) - await runUse({ configDir, io: realStreams(), bundle, workspaceId: args.workspaceId }) - } -} diff --git a/cli/src/commands/auth/use/use.test.ts b/cli/src/commands/auth/use/use.test.ts deleted file mode 100644 index 178785a630..0000000000 --- a/cli/src/commands/auth/use/use.test.ts +++ /dev/null @@ -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/) - }) -}) diff --git a/cli/src/commands/auth/use/use.ts b/cli/src/commands/auth/use/use.ts deleted file mode 100644 index 04454785b2..0000000000 --- a/cli/src/commands/auth/use/use.ts +++ /dev/null @@ -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 { - 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 } -} diff --git a/cli/src/commands/create/member/handlers.ts b/cli/src/commands/create/member/handlers.ts new file mode 100644 index 0000000000..c2e43577e7 --- /dev/null +++ b/cli/src/commands/create/member/handlers.ts @@ -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 + } +} diff --git a/cli/src/commands/create/member/index.ts b/cli/src/commands/create/member/index.ts new file mode 100644 index 0000000000..fe5b712769 --- /dev/null +++ b/cli/src/commands/create/member/index.ts @@ -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 }) + } +} diff --git a/cli/src/commands/create/member/run.test.ts b/cli/src/commands/create/member/run.test.ts new file mode 100644 index 0000000000..6086797d11 --- /dev/null +++ b/cli/src/commands/create/member/run.test.ts @@ -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' }) + }) +}) diff --git a/cli/src/commands/create/member/run.ts b/cli/src/commands/create/member/run.ts new file mode 100644 index 0000000000..f766124412 --- /dev/null +++ b/cli/src/commands/create/member/run.ts @@ -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 { + 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 } +} diff --git a/cli/src/commands/delete/member/handlers.ts b/cli/src/commands/delete/member/handlers.ts new file mode 100644 index 0000000000..1e88ee419e --- /dev/null +++ b/cli/src/commands/delete/member/handlers.ts @@ -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 + } +} diff --git a/cli/src/commands/delete/member/index.ts b/cli/src/commands/delete/member/index.ts new file mode 100644 index 0000000000..f455de9fbb --- /dev/null +++ b/cli/src/commands/delete/member/index.ts @@ -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 }) + } +} diff --git a/cli/src/commands/delete/member/run.test.ts b/cli/src/commands/delete/member/run.test.ts new file mode 100644 index 0000000000..27cdd347cf --- /dev/null +++ b/cli/src/commands/delete/member/run.test.ts @@ -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() + }) +}) diff --git a/cli/src/commands/delete/member/run.ts b/cli/src/commands/delete/member/run.ts new file mode 100644 index 0000000000..834eff0526 --- /dev/null +++ b/cli/src/commands/delete/member/run.ts @@ -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 { + if (opts.memberId === undefined || opts.memberId === '') { + throw new BaseError({ + code: ErrorCode.UsageMissingArg, + message: 'member id is required', + hint: 'pass it positionally: difyctl delete member ', + }) + } + + 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 { + 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() + } +} diff --git a/cli/src/commands/get/member/handlers.ts b/cli/src/commands/get/member/handlers.ts new file mode 100644 index 0000000000..b231916bec --- /dev/null +++ b/cli/src/commands/get/member/handlers.ts @@ -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 + } +} diff --git a/cli/src/commands/get/member/index.ts b/cli/src/commands/get/member/index.ts new file mode 100644 index 0000000000..44a3dd241a --- /dev/null +++ b/cli/src/commands/get/member/index.ts @@ -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 }) + } +} diff --git a/cli/src/commands/get/member/run.test.ts b/cli/src/commands/get/member/run.test.ts new file mode 100644 index 0000000000..72ae4c45b0 --- /dev/null +++ b/cli/src/commands/get/member/run.test.ts @@ -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') + }) +}) diff --git a/cli/src/commands/get/member/run.ts b/cli/src/commands/get/member/run.ts new file mode 100644 index 0000000000..844ac098ef --- /dev/null +++ b/cli/src/commands/get/member/run.ts @@ -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 { + 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 +} diff --git a/cli/src/commands/set/member/handlers.ts b/cli/src/commands/set/member/handlers.ts new file mode 100644 index 0000000000..23bd04c521 --- /dev/null +++ b/cli/src/commands/set/member/handlers.ts @@ -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 + } +} diff --git a/cli/src/commands/set/member/index.ts b/cli/src/commands/set/member/index.ts new file mode 100644 index 0000000000..3cbf3bf106 --- /dev/null +++ b/cli/src/commands/set/member/index.ts @@ -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 }) + } +} diff --git a/cli/src/commands/set/member/run.test.ts b/cli/src/commands/set/member/run.test.ts new file mode 100644 index 0000000000..4835e558c8 --- /dev/null +++ b/cli/src/commands/set/member/run.test.ts @@ -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' }) + }) +}) diff --git a/cli/src/commands/set/member/run.ts b/cli/src/commands/set/member/run.ts new file mode 100644 index 0000000000..7f4558506c --- /dev/null +++ b/cli/src/commands/set/member/run.ts @@ -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 { + if (opts.memberId === undefined || opts.memberId === '') { + throw new BaseError({ + code: ErrorCode.UsageMissingArg, + message: 'member id is required', + hint: 'pass it positionally: difyctl set member --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, + } +} diff --git a/cli/src/commands/tree.generated.ts b/cli/src/commands/tree.generated.ts index 666884917c..51a77d1a99 100644 --- a/cli/src/commands/tree.generated.ts +++ b/cli/src/commands/tree.generated.ts @@ -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: {} }, } diff --git a/cli/src/commands/use/workspace/index.ts b/cli/src/commands/use/workspace/index.ts new file mode 100644 index 0000000000..239ac9a44f --- /dev/null +++ b/cli/src/commands/use/workspace/index.ts @@ -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 { + 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, + }) + } +} diff --git a/cli/src/commands/use/workspace/use.test.ts b/cli/src/commands/use/workspace/use.test.ts new file mode 100644 index 0000000000..56199b76ef --- /dev/null +++ b/cli/src/commands/use/workspace/use.test.ts @@ -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 + list?: () => Promise +}) { + 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= 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/) + }) +}) diff --git a/cli/src/commands/use/workspace/use.ts b/cli/src/commands/use/workspace/use.ts new file mode 100644 index 0000000000..27aa001174 --- /dev/null +++ b/cli/src/commands/use/workspace/use.ts @@ -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//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 { + 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(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 +} diff --git a/cli/src/workspace/resolver.ts b/cli/src/workspace/resolver.ts index 225e65f666..1be313cd63 100644 --- a/cli/src/workspace/resolver.ts +++ b/cli/src/workspace/resolver.ts @@ -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 \'', }) } diff --git a/packages/contracts/generated/api/openapi/orpc.gen.ts b/packages/contracts/generated/api/openapi/orpc.gen.ts index c445ab877b..c837f6258e 100644 --- a/packages/contracts/generated/api/openapi/orpc.gen.ts +++ b/packages/contracts/generated/api/openapi/orpc.gen.ts @@ -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, } diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index b0ce8f427b..194ec6f363 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -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 + 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] diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 6f76b2a6d7..a98f0e3a86 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -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