diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index 998829098af777..a5e4ac06af3eea 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -37,6 +37,12 @@ DeviceMutateRequest, DeviceMutateResponse, DevicePollRequest, + MemberActionResponse, + MemberInvitePayload, + MemberInviteResponse, + MemberListResponse, + MemberResponse, + MemberRoleUpdatePayload, MessageMetadata, PermittedExternalAppsListQuery, PermittedExternalAppsListResponse, @@ -63,6 +69,8 @@ DevicePollRequest, DeviceLookupQuery, DeviceMutateRequest, + MemberInvitePayload, + MemberRoleUpdatePayload, PermittedExternalAppsListQuery, ) register_response_schema_models( @@ -86,6 +94,10 @@ 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 62d643c30f2865..e65991b8462b0b 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -6,7 +6,7 @@ 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. @@ -324,3 +324,48 @@ class PermittedExternalAppsListQuery(BaseModel): limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT) mode: AppMode | None = None name: str | None = Field(None, max_length=200) + + +# 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): + members: list[MemberResponse] + + +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 00000000000000..0f0286a925cd3a --- /dev/null +++ b/api/controllers/openapi/auth/role_gate.py @@ -0,0 +1,85 @@ +"""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 flask import g +from sqlalchemy import select +from werkzeug.exceptions import Forbidden, NotFound + +from extensions.ext_database import db +from models import TenantAccountJoin +from models.account import TenantAccountRole + +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 = getattr(g, "auth_ctx", None) + if ctx is None or getattr(ctx, "account_id", None) 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" + ) + + join = db.session.execute( + select(TenantAccountJoin).where( + TenantAccountJoin.tenant_id == str(workspace_id), + TenantAccountJoin.account_id == str(ctx.account_id), + ) + ).scalar_one_or_none() + + if join is None: + raise NotFound("workspace not found") + + if allowed and TenantAccountRole(join.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 d94381daebabce..7b45ad0a51aa13 100644 --- a/api/controllers/openapi/workspaces.py +++ b/api/controllers/openapi/workspaces.py @@ -1,22 +1,40 @@ -"""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 g +from flask import g, jsonify, make_response, request from flask_restx import Resource +from pydantic import BaseModel, ValidationError from sqlalchemy import select -from werkzeug.exceptions import NotFound +from werkzeug.exceptions import BadRequest, Forbidden, NotFound +from configs import dify_config from controllers.openapi import openapi_ns -from controllers.openapi._models import WorkspaceDetailResponse, WorkspaceListResponse, WorkspaceSummaryResponse +from controllers.openapi._models import ( + MemberActionResponse, + MemberInvitePayload, + MemberInviteResponse, + 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 ( @@ -24,7 +42,107 @@ SubjectType, validate_bearer, ) -from models import Tenant, TenantAccountJoin +from models import Account, Tenant, TenantAccountJoin +from models.account import TenantAccountRole +from services.account_service import AccountService, RegisterService, TenantService +from services.errors.account import ( + AccountAlreadyInTenantError, + AccountNotLinkTenantError, + 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(exc.json()) + + +def _member_response(account: Account) -> MemberResponse: + return MemberResponse( + id=str(account.id), + name=account.name, + email=account.email, + role=str(account.role), + status=str(account.status), + avatar=account.avatar, + ) + + +def _load_tenant(workspace_id: str) -> Tenant: + tenant = db.session.get(Tenant, workspace_id) + if tenant is None: + # require_workspace_role has already verified membership, so a + # missing Tenant here means the row vanished between the gate + # query and now — treat the same as non-member. + 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 = db.session.get(Account, 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") @@ -69,6 +187,175 @@ def get(self, workspace_id: str): 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 = g.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 = db.session.execute( + select(Tenant, TenantAccountJoin) + .join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id) + .where( + Tenant.id == workspace_id, + TenantAccountJoin.account_id == str(ctx.account_id), + ) + ).first() + 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.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): + tenant = _load_tenant(workspace_id) + members = TenantService.get_tenant_members(tenant) + return MemberListResponse( + members=[_member_response(m) for m in members], + ).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 = g.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)) + + 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 = g.auth_ctx + operator = _load_account(ctx.account_id) + tenant = _load_tenant(workspace_id) + member = db.session.get(Account, 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 = g.auth_ctx + operator = _load_account(ctx.account_id) + tenant = _load_tenant(workspace_id) + member = db.session.get(Account, 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/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 00000000000000..d24a94af08bcf4 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_role_gate.py @@ -0,0 +1,286 @@ +"""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 g.auth_ctx / account_id / workspace_id — + those are wiring bugs, not user-driven failures +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from flask import Flask, g +from werkzeug.exceptions import Forbidden, NotFound + +from controllers.openapi.auth.role_gate import require_workspace_role +from libs.oauth_bearer import AuthContext, Scope, SubjectType +from models.account import TenantAccountRole + + +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={}, + ) + + +def _join(role: TenantAccountRole) -> SimpleNamespace: + return SimpleNamespace(role=role) + + +def _scalar(value: object) -> MagicMock: + """Build a MagicMock that mimics `db.session.execute(...).scalar_one_or_none()`.""" + + result = MagicMock() + result.scalar_one_or_none.return_value = value + return result + + +# --------------------------------------------------------------------------- +# 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"): + g.auth_ctx = _account_ctx() + with patch("controllers.openapi.auth.role_gate.db") as mock_db: + mock_db.session.execute.return_value = _scalar(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"): + g.auth_ctx = _account_ctx() + with patch("controllers.openapi.auth.role_gate.db") as mock_db: + mock_db.session.execute.return_value = _scalar(_join(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"): + g.auth_ctx = _account_ctx() + with patch("controllers.openapi.auth.role_gate.db") as mock_db: + mock_db.session.execute.return_value = _scalar(_join(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"): + g.auth_ctx = _account_ctx() + with patch("controllers.openapi.auth.role_gate.db") as mock_db: + mock_db.session.execute.return_value = _scalar(_join(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"): + g.auth_ctx = _account_ctx() + with patch("controllers.openapi.auth.role_gate.db") as mock_db: + mock_db.session.execute.return_value = _scalar(_join(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"): + g.auth_ctx = _account_ctx() + with patch("controllers.openapi.auth.role_gate.db") as mock_db: + mock_db.session.execute.return_value = _scalar(_join(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"): + g.auth_ctx = _account_ctx() + with patch("controllers.openapi.auth.role_gate.db") as mock_db: + mock_db.session.execute.return_value = _scalar(None) + with pytest.raises(NotFound): + view(workspace_id=workspace_id) + + +# --------------------------------------------------------------------------- +# Query is scoped to the caller's account_id and the URL workspace_id +# --------------------------------------------------------------------------- + + +def test_query_is_scoped_to_caller_and_workspace(): + """The decorator must look up `(workspace_id, caller's account_id)` — + otherwise a member of workspace A could quietly hit endpoints for + workspace B. Inspect the SQLAlchemy expressions we end up handing to + `db.session.execute` to make that guarantee load-bearing. + """ + + 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"): + g.auth_ctx = _account_ctx(account_id=account_id) + with patch("controllers.openapi.auth.role_gate.db") as mock_db: + mock_db.session.execute.return_value = _scalar(_join(TenantAccountRole.NORMAL)) + view(workspace_id=workspace_id) + + stmt = mock_db.session.execute.call_args.args[0] + compiled = str(stmt.compile(compile_kwargs={"literal_binds": True})) + assert workspace_id in compiled + assert str(account_id) in compiled + + +# --------------------------------------------------------------------------- +# Wiring bugs surface as RuntimeError (loud), not 403 (silent) +# --------------------------------------------------------------------------- + + +def test_missing_g_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_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"): + g.auth_ctx = _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"): + g.auth_ctx = _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 00000000000000..4772e729332aa9 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py @@ -0,0 +1,786 @@ +"""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; `g.auth_ctx` is seeded manually, 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, g +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 +from models.account import AccountStatus, TenantAccountRole +from services.errors.account import ( + AccountAlreadyInTenantError, + AccountNotLinkTenantError, + CannotOperateSelfError, + MemberNotInTenantError, + NoPermissionError, + RoleAlreadyAssignedError, +) + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@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), + ) + + +# --------------------------------------------------------------------------- +# 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", + SimpleNamespace( + switch_tenant=switch_mock, + get_tenant_members=Mock(return_value=[]), + remove_member_from_tenant=Mock(), + update_member_role=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"): + g.auth_ctx = _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", + SimpleNamespace( + switch_tenant=Mock(side_effect=AccountNotLinkTenantError("…")), + get_tenant_members=Mock(), + remove_member_from_tenant=Mock(), + update_member_role=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"): + g.auth_ctx = _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", + SimpleNamespace( + switch_tenant=Mock(), + get_tenant_members=Mock(return_value=[member]), + remove_member_from_tenant=Mock(), + update_member_role=Mock(), + ), + ) + monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db) + + with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members"): + g.auth_ctx = _auth_ctx(account_id=acct_id) + body, status = api.get.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id) + + assert status == 200 + assert body["members"][0]["email"] == "mia@example.com" + assert body["members"][0]["role"] == "admin" + assert body["members"][0]["status"] == "active" + + +# --------------------------------------------------------------------------- +# 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", + SimpleNamespace(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", + ): + g.auth_ctx = _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): + g.auth_ctx = _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): + g.auth_ctx = _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", + SimpleNamespace(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): + g.auth_ctx = _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", + ): + g.auth_ctx = _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", + SimpleNamespace( + switch_tenant=Mock(), + get_tenant_members=Mock(), + remove_member_from_tenant=remove_mock, + update_member_role=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", + ): + g.auth_ctx = _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", + SimpleNamespace( + switch_tenant=Mock(), + get_tenant_members=Mock(), + remove_member_from_tenant=Mock(side_effect=exc), + update_member_role=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", + ): + g.auth_ctx = _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", + ): + g.auth_ctx = _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", + SimpleNamespace( + switch_tenant=Mock(), + get_tenant_members=Mock(), + remove_member_from_tenant=Mock(), + 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", + ): + g.auth_ctx = _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", + SimpleNamespace( + switch_tenant=Mock(), + get_tenant_members=Mock(), + remove_member_from_tenant=Mock(), + 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", + ): + g.auth_ctx = _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", + SimpleNamespace( + switch_tenant=switch_mock, + get_tenant_members=Mock(), + remove_member_from_tenant=Mock(), + update_member_role=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"): + g.auth_ctx = _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() diff --git a/cli/ARD.md b/cli/ARD.md index b8813fe920deb2..de7a4b359f5a00 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/members.test.ts b/cli/src/api/members.test.ts new file mode 100644 index 00000000000000..8386be568b7b3a --- /dev/null +++ b/cli/src/api/members.test.ts @@ -0,0 +1,263 @@ +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 body', async () => { + const captured: StubServer['lastRequest'] = {} + stub = await startServer( + jsonResponder( + 200, + { + members: [ + { 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.members[0].email).toBe('mia@e.com') + }) + + it('URL-encodes workspace id', async () => { + const captured: StubServer['lastRequest'] = {} + stub = await startServer(jsonResponder(200, { members: [] }, captured)) + stub.lastRequest = captured + + await makeClient(stub.url).list('ws with space') + expect(captured.url).toBe('/openapi/v1/workspaces/ws%20with%20space/members') + }) + + 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 00000000000000..7857b04b114b4a --- /dev/null +++ b/cli/src/api/members.ts @@ -0,0 +1,54 @@ +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): Promise { + return this.http + .get(`workspaces/${encodeURIComponent(workspaceId)}/members`) + .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 a3feac23d0e27c..3a36c86ed4e578 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,18 @@ export class WorkspacesClient { async list(): Promise { return this.http.get('workspaces').json() } + + /** + * Server-side workspace switch. Mirrors 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/use/index.ts b/cli/src/commands/auth/use/index.ts deleted file mode 100644 index 5803e6450eb31f..00000000000000 --- 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 178785a630a2ee..00000000000000 --- 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 04454785b2f6c4..00000000000000 --- 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 00000000000000..c2e43577e75e12 --- /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 00000000000000..fe5b712769cce4 --- /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 00000000000000..6086797d112099 --- /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 00000000000000..f7661244125901 --- /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 00000000000000..1e88ee419e9475 --- /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 00000000000000..80a04b9af3f11c --- /dev/null +++ b/cli/src/commands/delete/member/index.ts @@ -0,0 +1,39 @@ +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: '' }), + } + + 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 }, + { 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 00000000000000..27cdd347cf887e --- /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 00000000000000..af762022fb4eb2 --- /dev/null +++ b/cli/src/commands/delete/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 { 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 +} + +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, + }) + + 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, + } +} diff --git a/cli/src/commands/get/member/handlers.ts b/cli/src/commands/get/member/handlers.ts new file mode 100644 index 00000000000000..b231916bec2133 --- /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 00000000000000..5671cebdc59177 --- /dev/null +++ b/cli/src/commands/get/member/index.ts @@ -0,0 +1,36 @@ +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 -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)', + }), + '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, 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 00000000000000..85f29e49225bc0 --- /dev/null +++ b/cli/src/commands/get/member/run.test.ts @@ -0,0 +1,131 @@ +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 = { + members: [ + { 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') + 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') + expect(r.workspaceId).toBe('ws-9') + }) + + 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 = { + members: [ + { 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().members[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 00000000000000..b7dc33af2ad896 --- /dev/null +++ b/cli/src/commands/get/member/run.ts @@ -0,0 +1,50 @@ +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 { resolveWorkspaceId } from '../../../workspace/resolver.js' +import { MemberListOutput, MemberRow } from './handlers.js' + +export type GetMemberOptions = { + readonly workspace?: 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 envelope = await runWithSpinner( + { io, label: 'Fetching members' }, + () => factory(deps.http).list(wsId), + ) + + const callerId = deps.bundle.account?.id ?? '' + const rows = envelope.members.map(m => new MemberRow(m, callerId !== '' && m.id === callerId)) + return { data: new MemberListOutput(rows, envelope), workspaceId: wsId } +} diff --git a/cli/src/commands/set/member/handlers.ts b/cli/src/commands/set/member/handlers.ts new file mode 100644 index 00000000000000..23bd04c5218f01 --- /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 00000000000000..3cbf3bf1061444 --- /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 00000000000000..4835e558c8da15 --- /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 00000000000000..7f4558506cc84e --- /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 666884917cb798..51a77d1a996982 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 00000000000000..239ac9a44f788d --- /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 00000000000000..56199b76ef508c --- /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 00000000000000..27aa0011747646 --- /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 225e65f6669c4f..1be313cd638e90 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/README.md b/packages/contracts/README.md index cff5fe32f930fc..92ba265913a080 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -8,15 +8,15 @@ Snapshot generated from `packages/contracts/generated/api/readiness.json` after running `pnpm -C packages/contracts gen-api-contract-from-openapi`. -Are we OpenAPI ready? **No.** Current generated API contracts are **18.1% ready**. +Are we OpenAPI ready? **No.** Current generated API contracts are **18.7% ready**. | Surface | Ready | Not ready | Total | Ready % | | --------- | ------: | --------: | ------: | --------: | | console | 96 | 474 | 570 | 16.8% | -| openapi | 13 | 8 | 21 | 61.9% | +| openapi | 19 | 8 | 27 | 70.4% | | service | 16 | 72 | 88 | 18.2% | | web | 5 | 36 | 41 | 12.2% | -| **total** | **130** | **590** | **720** | **18.1%** | +| **total** | **136** | **590** | **726** | **18.7%** | Readiness here means the generated contract operation is not marked with: diff --git a/packages/contracts/generated/api/openapi/orpc.gen.ts b/packages/contracts/generated/api/openapi/orpc.gen.ts index b266b7d0cb49fa..0dc54703748481 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,9 +25,13 @@ import { zGetOauthDeviceLookupResponse, zGetPermittedExternalAppsResponse, zGetVersionResponse, + zGetWorkspacesByWorkspaceIdMembersPath, + zGetWorkspacesByWorkspaceIdMembersResponse, zGetWorkspacesByWorkspaceIdPath, zGetWorkspacesByWorkspaceIdResponse, zGetWorkspacesResponse, + zPostAppsByAppIdFilesUploadPath, + zPostAppsByAppIdFilesUploadResponse, zPostAppsByAppIdFormHumanInputByFormTokenBody, zPostAppsByAppIdFormHumanInputByFormTokenPath, zPostAppsByAppIdFormHumanInputByFormTokenResponse, @@ -42,6 +48,14 @@ import { zPostOauthDeviceDenyResponse, zPostOauthDeviceTokenBody, zPostOauthDeviceTokenResponse, + zPostWorkspacesByWorkspaceIdMembersBody, + zPostWorkspacesByWorkspaceIdMembersPath, + zPostWorkspacesByWorkspaceIdMembersResponse, + zPostWorkspacesByWorkspaceIdSwitchPath, + zPostWorkspacesByWorkspaceIdSwitchResponse, + zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleBody, + zPutWorkspacesByWorkspaceIdMembersByMemberIdRolePath, + zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse, } from './zod.gen' /** @@ -168,6 +182,30 @@ export const describe = { get: get5, } +/** + * Upload a file to use as an input variable when running the app + */ +export const post = oc + .route({ + description: 'Upload a file to use as an input variable when running the app', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdFilesUpload', + path: '/apps/{app_id}/files/upload', + successStatus: 201, + tags: ['openapi'], + }) + .input(z.object({ params: zPostAppsByAppIdFilesUploadPath })) + .output(zPostAppsByAppIdFilesUploadResponse) + +export const upload = { + post, +} + +export const files = { + upload, +} + /** * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. * @@ -192,7 +230,7 @@ export const get6 = oc * * @deprecated */ -export const post = oc +export const post2 = oc .route({ deprecated: true, description: @@ -213,7 +251,7 @@ export const post = oc export const byFormToken = { get: get6, - post, + post: post2, } export const humanInput = { @@ -229,7 +267,7 @@ export const form = { * * @deprecated */ -export const post2 = oc +export const post3 = oc .route({ deprecated: true, description: @@ -244,7 +282,7 @@ export const post2 = oc .output(zPostAppsByAppIdRunResponse) export const run = { - post: post2, + post: post3, } /** @@ -275,7 +313,7 @@ export const events = { * * @deprecated */ -export const post3 = oc +export const post4 = oc .route({ deprecated: true, description: @@ -290,7 +328,7 @@ export const post3 = oc .output(zPostAppsByAppIdTasksByTaskIdStopResponse) export const stop = { - post: post3, + post: post4, } export const byTaskId = { @@ -304,6 +342,7 @@ export const tasks = { export const byAppId = { describe, + files, form, run, tasks, @@ -325,7 +364,7 @@ export const apps = { byAppId, } -export const post4 = oc +export const post5 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -337,10 +376,10 @@ export const post4 = oc .output(zPostOauthDeviceApproveResponse) export const approve = { - post: post4, + post: post5, } -export const post5 = oc +export const post6 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -352,10 +391,10 @@ export const post5 = oc .output(zPostOauthDeviceCodeResponse) export const code = { - post: post5, + post: post6, } -export const post6 = oc +export const post7 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -367,7 +406,7 @@ export const post6 = oc .output(zPostOauthDeviceDenyResponse) export const deny = { - post: post6, + post: post7, } export const get9 = oc @@ -390,7 +429,7 @@ export const lookup = { * * @deprecated */ -export const post7 = oc +export const post8 = oc .route({ deprecated: true, description: @@ -405,7 +444,7 @@ export const post7 = oc .output(zPostOauthDeviceTokenResponse) export const token = { - post: post7, + post: post8, } export const device = { @@ -434,7 +473,92 @@ 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 })) + .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', @@ -446,10 +570,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', @@ -460,7 +586,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 eaf1d5ae91561c..30ef4c3f37679b 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -143,6 +143,23 @@ export type DevicePollRequest = { device_code: string } +export type FileResponse = { + conversation_id?: string | null + created_at?: number | null + created_by?: string | null + extension?: string | null + file_key?: string | null + id: string + mime_type?: string | null + name: string + original_url?: string | null + preview_url?: string | null + size: number + source_url?: string | null + tenant_id?: string | null + user_id?: string | null +} + export type HumanInputFormSubmitPayload = { action: string inputs: { @@ -152,6 +169,41 @@ 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 MemberListResponse = { + members: Array +} + +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 @@ -377,6 +429,40 @@ export type GetAppsByAppIdDescribeResponses = { export type GetAppsByAppIdDescribeResponse = GetAppsByAppIdDescribeResponses[keyof GetAppsByAppIdDescribeResponses] +export type PostAppsByAppIdFilesUploadData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/files/upload' +} + +export type PostAppsByAppIdFilesUploadErrors = { + 400: { + [key: string]: unknown + } + 401: { + [key: string]: unknown + } + 413: { + [key: string]: unknown + } + 415: { + [key: string]: unknown + } +} + +export type PostAppsByAppIdFilesUploadError + = PostAppsByAppIdFilesUploadErrors[keyof PostAppsByAppIdFilesUploadErrors] + +export type PostAppsByAppIdFilesUploadResponses = { + 201: FileResponse +} + +export type PostAppsByAppIdFilesUploadResponse + = PostAppsByAppIdFilesUploadResponses[keyof PostAppsByAppIdFilesUploadResponses] + export type GetAppsByAppIdFormHumanInputByFormTokenData = { body?: never path: { @@ -587,3 +673,85 @@ export type GetWorkspacesByWorkspaceIdResponses = { export type GetWorkspacesByWorkspaceIdResponse = GetWorkspacesByWorkspaceIdResponses[keyof GetWorkspacesByWorkspaceIdResponses] + +export type GetWorkspacesByWorkspaceIdMembersData = { + body?: never + path: { + workspace_id: string + } + query?: never + 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 d7d8b9eff4e0aa..1823d48c8f2781 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -120,6 +120,26 @@ export const zDevicePollRequest = z.object({ device_code: z.string(), }) +/** + * FileResponse + */ +export const zFileResponse = z.object({ + conversation_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + extension: z.string().nullish(), + file_key: z.string().nullish(), + id: z.string(), + mime_type: z.string().nullish(), + name: z.string(), + original_url: z.string().nullish(), + preview_url: z.string().nullish(), + size: z.int(), + source_url: z.string().nullish(), + tenant_id: z.string().nullish(), + user_id: z.string().nullish(), +}) + export const zJsonValue = z.unknown() /** @@ -130,6 +150,59 @@ 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(), +}) + +/** + * 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({ + members: z.array(zMemberResponse), +}) + +/** + * MemberRoleUpdatePayload + */ +export const zMemberRoleUpdatePayload = z.object({ + role: z.enum(['admin', 'normal']), +}) + /** * PermittedExternalAppsListQuery * @@ -409,6 +482,15 @@ export const zGetAppsByAppIdDescribeQuery = z.object({ */ export const zGetAppsByAppIdDescribeResponse = zAppDescribeResponse +export const zPostAppsByAppIdFilesUploadPath = z.object({ + app_id: z.string(), +}) + +/** + * File uploaded successfully + */ +export const zPostAppsByAppIdFilesUploadResponse = zFileResponse + export const zGetAppsByAppIdFormHumanInputByFormTokenPath = z.object({ app_id: z.string(), form_token: z.string(), @@ -517,3 +599,54 @@ export const zGetWorkspacesByWorkspaceIdPath = z.object({ * Workspace detail */ export const zGetWorkspacesByWorkspaceIdResponse = zWorkspaceDetailResponse + +export const zGetWorkspacesByWorkspaceIdMembersPath = z.object({ + workspace_id: z.string(), +}) + +/** + * 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 diff --git a/packages/contracts/generated/api/readiness.json b/packages/contracts/generated/api/readiness.json index 6c38aa166464af..8c880e98e1d30e 100644 --- a/packages/contracts/generated/api/readiness.json +++ b/packages/contracts/generated/api/readiness.json @@ -6,7 +6,7 @@ }, "openapi": { "notReady": 8, - "total": 21 + "total": 27 }, "service": { "notReady": 72,