Skip to content

feat(openapi,cli): workspace switch + member management#36651

Open
lin-snow wants to merge 15 commits into
langgenius:mainfrom
lin-snow:feat/cli-workspace-members
Open

feat(openapi,cli): workspace switch + member management#36651
lin-snow wants to merge 15 commits into
langgenius:mainfrom
lin-snow:feat/cli-workspace-members

Conversation

@lin-snow
Copy link
Copy Markdown
Contributor

@lin-snow lin-snow commented May 26, 2026

Summary

Adds workspace switching and member management to the bearer-authenticated /openapi/v1 surface, plus the matching difyctl commands. These endpoints are role-gated against the caller's membership in the target workspace, so they are safe to expose to programmatic (CLI / API) clients.

New /openapi/v1 endpoints

Method Route Access Purpose
POST /workspaces/{id}/switch any member Switch the bearer's active workspace
GET /workspaces/{id}/members any member List members (paginated: page / limit)
POST /workspaces/{id}/members owner / admin Invite a member
PUT /workspaces/{id}/members/{member_id}/role owner / admin Change a member's role
DELETE /workspaces/{id}/members/{member_id} owner / admin Remove a member

A new @require_workspace_role(*roles) decorator centralizes the gate. It returns 404 for non-members (parity with GET /workspaces/{id} — no cross-tenant ID leak) and 403 for members whose role is not allowed. Member invites additionally pass through edition-aware quota gates.

Request flow

sequenceDiagram
    participant CLI as difyctl
    participant API as /openapi/v1
    participant Gate as require_workspace_role
    CLI->>API: Bearer dfoa_… + workspace_id
    API->>API: validate_bearer → set_auth_ctx (ContextVar)
    API->>Gate: accept_subjects(ACCOUNT)
    Gate->>Gate: try_get_auth_ctx() → account_id
    Gate->>Gate: lookup TenantAccountJoin(workspace_id, account_id)
    alt not a member
        Gate-->>CLI: 404
    else role not allowed
        Gate-->>CLI: 403
    else
        Gate->>API: invoke handler → 200
    end
Loading

New difyctl commands

Command Notes
difyctl use workspace Set the active workspace for the current host
difyctl get member List members; --page / --limit pagination
difyctl create member Invite a member
difyctl set member Update a member's role
difyctl delete member Remove a member

create / set / delete member accept -o json|yaml|name|text; auth devices list also gained --page / --limit.

Included fix

require_workspace_role and the member handlers now read the caller identity from the openapi auth ContextVar (try_get_auth_ctx() / get_auth_ctx()), the slot validate_bearer actually publishes — not flask.g.auth_ctx, which production never writes. The earlier flask.g read made every role-gated request raise RuntimeError → 500 while unit tests stayed green (they seeded g directly). A regression test now seeds only g, leaves the ContextVar empty, and asserts the gate still rejects — pinning the identity source.

Test plan

  • Backend: api/tests/unit_tests/controllers/openapi/auth/test_role_gate.py (gate: 404 / 403 / pass, query scoping, wiring-bug RuntimeErrors) and test_workspaces_members.py (endpoint behaviors).
  • CLI: unit tests per command against a stub server (*.test.ts).
  • Manual: exercised all endpoints against a live EE server via curl and the real difyctl binary — switch / list / invite / role-change / remove all return expected results.

Checklist

  • This change requires a documentation update, included: Dify Document
  • I understand that this PR may be closed in case there was no previous discussion or issues. (This doesn't apply to typos!)
  • I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
  • I've updated the documentation accordingly.
  • I ran make lint && make type-check (backend) and cd web && pnpm exec vp staged (frontend) to appease the lint gods

@dosubot dosubot Bot added the size:XXL This PR changes 1000+ lines, ignoring generated files. label May 26, 2026
@github-actions github-actions Bot added the web This relates to changes on the web. label May 26, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 26, 2026

Pyrefly Diff

base → PR
--- /tmp/pyrefly_base.txt	2026-05-26 10:17:05.638672351 +0000
+++ /tmp/pyrefly_pr.txt	2026-05-26 10:16:51.348545989 +0000
@@ -557,7 +557,7 @@
 ERROR No matching overload found for function `redis.client.Redis.__init__` called with arguments: (host=int | str | Unknown, port=int | str | Unknown, password=int | str | Unknown | None, db=int, ssl=bool, ssl_ca_certs=str | None, ssl_cert_reqs=Any | None, ssl_certfile=Any | None, ssl_keyfile=Any | None, socket_timeout=Literal[5], socket_connect_timeout=Literal[5], health_check_interval=Literal[30]) [no-matching-overload]
   --> schedule/queue_monitor_task.py:14:21
 ERROR Object of class `Tenant` has no attribute `role` [missing-attribute]
-    --> services/account_service.py:1355:13
+    --> services/account_service.py:1383:13
 ERROR `+` is not supported between `str` and `dict[Unknown, Unknown]` [unsupported-operation]
    --> services/app_service.py:519:53
 ERROR No matching overload found for function `flask.helpers.stream_with_context` called with arguments: (Generator[bytes]) [no-matching-overload]
@@ -2266,6 +2266,26 @@
   --> tests/unit_tests/controllers/openapi/test_workspaces.py:49:12
 ERROR `in` is not supported between `Literal['GET']` and `None` [not-iterable]
   --> tests/unit_tests/controllers/openapi/test_workspaces.py:50:12
+ERROR Object of class `FunctionType` has no attribute `view_class` [missing-attribute]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:164:12
+ERROR `in` is not supported between `Literal['POST']` and `None` [not-iterable]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:165:12
+ERROR Object of class `FunctionType` has no attribute `view_class` [missing-attribute]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:170:12
+ERROR `in` is not supported between `Literal['GET']` and `None` [not-iterable]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:171:12
+ERROR `in` is not supported between `Literal['POST']` and `None` [not-iterable]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:172:12
+ERROR Object of class `FunctionType` has no attribute `view_class` [missing-attribute]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:177:12
+ERROR `in` is not supported between `Literal['DELETE']` and `None` [not-iterable]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:178:12
+ERROR Object of class `FunctionType` has no attribute `view_class` [missing-attribute]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:183:12
+ERROR `in` is not supported between `Literal['PUT']` and `None` [not-iterable]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:184:12
+ERROR Object of class `NoneType` has no attribute `json`
+ERROR Object of class `NoneType` has no attribute `json`
 ERROR Cannot index into `Iterable[bytes]` [bad-index]
    --> tests/unit_tests/controllers/service_api/app/test_audio.py:190:16
 ERROR Cannot index into `Response` [bad-index]

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 26, 2026

Pyrefly Type Coverage

Metric Base PR Delta
Type coverage 45.58% 45.51% -0.07%
Strict coverage 45.10% 45.03% -0.07%
Typed symbols 24,188 24,206 +18
Untyped symbols 29,184 29,293 +109
Modules 2724 2727 +3

@lin-snow lin-snow self-assigned this May 26, 2026
@lin-snow lin-snow marked this pull request as draft May 26, 2026 04:32
@lin-snow lin-snow force-pushed the feat/cli-workspace-members branch from d848820 to e657d25 Compare May 26, 2026 08:08
@lin-snow lin-snow changed the title feat(openapi,cli): workspace switch + member management for /openapi/v1 and difyctl feat(openapi,cli): workspace switch + role-gated member management May 26, 2026
@lin-snow lin-snow changed the title feat(openapi,cli): workspace switch + role-gated member management feat(openapi,cli): workspace switch + member management May 26, 2026
@lin-snow lin-snow removed request for a team, QuantumGhost, crazywoola and laipz8200 May 26, 2026 08:45
@lin-snow lin-snow marked this pull request as ready for review May 26, 2026 08:45
Comment thread api/controllers/openapi/auth/role_gate.py Outdated
@lin-snow lin-snow force-pushed the feat/cli-workspace-members branch from e657d25 to 5369a51 Compare May 26, 2026 09:47
Comment thread api/controllers/openapi/workspaces.py Outdated
lin-snow and others added 9 commits May 26, 2026 18:11
…ted openapi

Adds five bearer-authed endpoints under /openapi/v1/workspaces/<id>/
(switch, members CRUD, role update) gated by a new
@require_workspace_role decorator that returns 404 for non-members
(matching the existing GET /workspaces/<id> convention so workspace
IDs don't leak across tenants) and 403 for insufficient role.
TenantService / RegisterService domain logic is reused as-is — invites
still go through invite_new_member so the Celery activation email
fires for newly-invited addresses. Owner is intentionally not
assignable through invite or role-update; ownership transfer remains
console-only.

CLI gains five commands:

  difyctl use workspace <id>
  difyctl get member [-w <id>] [-o ...]
  difyctl create member --email <e> --role <r> [-w <id>]
  difyctl delete member <member-id> [-w <id>]
  difyctl set member <member-id> --role <r> [-w <id>]

use workspace strictly orders POST /switch -> GET /workspaces ->
saveHosts; any failure aborts with no local mutation so hosts.yml
never diverges from the server. get member marks the calling account
row with '*' (matched via hosts.yml bundle.account.id). --role is
client-enum-validated to normal|admin before any HTTP call.

The old `difyctl auth use` (a pure-local workspace picker) is
removed — its semantics conflict with server-side switch and keeping
it would only confuse. The "no workspace selected" hint now points
at `difyctl use workspace <id>`.
Inline checks on POST /openapi/v1/workspaces/<id>/members for:
- SaaS subscription members.limit (members.limit_exceeded)
- EE license workspace_members cap (workspace_members.license_exceeded)

Envelope {code, message, hint} on the wire body so CLI error-mapper
can surface structured remediation guidance without edition awareness.
EE per-workspace allow_member_invite policy continues via service-layer
check_workspace_member_invite_permission inside invite_new_member.
Reruns pnpm gen-api-contract and pnpm tree:gen after rebasing onto
upstream/feat/cli (which migrated CLI types to @dify/contracts). Adds
the Member* types to the shared contract package and registers the
new CLI commands (use workspace, create/delete/get/set member) in
the build-time command tree.
…Workspace + simplify _member_response

- invite_url is always set server-side (always-non-null URL build path);
  drop the misleading Optional so generated CLI/SDK types stop forcing
  callers through pointless null checks.
- use/workspace: pickWorkspace was used in one of two adjacent shape
  conversions; inline both for symmetry.
- _member_response: TenantAccountRole and AccountStatus are StrEnums —
  the getattr + `if role else ""` defenses are unreachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
create member -o json now surfaces the full MemberInviteResponse —
including invite_url, previously unreachable from the CLI (scripts
had to rely on the Celery activation email). set/delete return a
synthesized {id, role} / {id, deleted: true} payload; the server's
200 is the proof the mutation took, so no extra round-trip and no
race on concurrent role flips.

Each command grew a small *Output class implementing the framework's
FormattedPrintable (text/json) + NamePrintable. run.ts builds it
(colored success line precomputed); index.ts wraps in formatted()
and lets the runner emit. Mirrors get member's existing
table()-envelope pattern. No backend changes, no spec changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Reject member operations on non-NORMAL tenants in _load_tenant (archived
  workspaces were accessible via bearer auth without the console's implicit
  session filter)
- Catch AccountRegisterError in invite endpoint so frozen-email / workspace-
  creation-blocked scenarios return 400 instead of bubbling as 500
- Defend _member_response against None role/status producing literal "None"
- Add --yes/-y flag and interactive TTY confirmation to `difyctl delete member`

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Wrap GET /workspaces/{id}/members in a {page, limit, total, has_more,
data} envelope and add MemberListQuery (strict, page >= 1, limit in
[1, 200]) as the query schema.

Pagination is still done in-memory: per-workspace member counts are
bounded by SaaS plan caps and EE seat licenses, so pushing pagination
into the service layer would be churn without upside. Left a comment
on the controller documenting that choice.

Tests cover the paginated slice and the extra='forbid' 400 path for
unknown query params (e.g. ?pg=2).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Output of `pnpm -C packages/contracts gen-api-contract` after the
MemberListResponse envelope + MemberListQuery schema change. No
hand-written drift — re-running the codegen reproduces these files
byte-for-byte.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Forward {page, limit} through MembersClient.list and
AccountSessionsClient.list as URLSearchParams. Add --page/--limit
flags on `get member` and `auth devices list`; --limit also accepts
the DIFY_LIMIT env var via the shared parseLimit helper.

`auth devices revoke` now uses a new listAllSessions() helper that
walks pages until has_more=false (at LIMIT_MAX per call, hard-capped
at 100 pages to defend against a server that lies about has_more).
Without exhaustive paging, a session past page 1 would silently be
un-revokable by label/prefix.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
lin-snow and others added 5 commits May 26, 2026 18:11
- map pydantic ValidationError to BadRequest(str(exc)) instead of
  exc.json() in both body and query validators, for readable 400
  messages consistent with oauth_device helpers
- clarify WorkspacesClient.switch docstring to name the actual OpenAPI
  route and mark it as the bearer-authed equivalent of console switch
- fix 'revokable' -> 'revocable' typo in listAllSessions comment
require_workspace_role and the four member/switch handlers read the
caller identity off `g.auth_ctx`, but nothing in production ever writes
that slot — `validate_bearer` publishes identity to the openapi auth
ContextVar (`set_auth_ctx`). Every real request to a `@require_workspace_role`
endpoint therefore hit the gate's "no account context" RuntimeError and
500'd (switch + the four member endpoints). Verified against a live
SELF_HOSTED server: `difyctl get member` / `use workspace` now return 200.

The unit suite stayed green because it stripped the decorators via
`__wrapped__` and seeded `g.auth_ctx` directly — mocking away the exact
broken link. Rework both test modules to seed the ContextVar via
`set_auth_ctx` (the slot production fills) with a per-test reset fixture,
and add a regression test that seeds only `flask.g` and asserts the gate
still raises — pinning the identity source to the ContextVar.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The require_workspace_role gate ran an inline TenantAccountJoin query,
crossing the controller/service boundary. Add
TenantService.get_account_role_in_tenant (session-injected, mirroring
account_belongs_to_tenant) returning the caller's role or None, and
reduce the gate to pure policy: None -> 404 (no tenant-id leak),
out-of-set role -> 403.

Tests follow the layering: the gate tests stub the service method, and
the SQL-scoping assertion moves down to TestTenantService alongside
member / non-member / empty-account_id cases.
Remove all direct db.session.xxx calls from workspaces.py per review:

- _load_tenant         -> TenantService.get_tenant_by_id
- _load_account/member -> AccountService.get_account_by_id
- switch re-query      -> TenantService.find_workspace_for_account
  (the inline SELECT was a verbatim duplicate of that method)

No new service code — these getters already existed and are session-
injected. The controller keeps only HTTP concerns (status->404 mapping,
null->NotFound). Drops the now-unused sqlalchemy.select import.

Tests: controller stubs gain session-delegating getters via two factory
helpers, so existing mock_db.session expectations and side_effect order
are preserved; the SQL is covered in test_account_service.py.
@lin-snow lin-snow force-pushed the feat/cli-workspace-members branch from 05ad531 to ec37b8c Compare May 26, 2026 10:11
@lin-snow lin-snow requested review from GareArc and wylswz May 26, 2026 10:41
@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label May 26, 2026
@wylswz wylswz added this pull request to the merge queue May 26, 2026
@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks May 26, 2026
@lin-snow lin-snow added this pull request to the merge queue May 26, 2026
@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to failed status checks May 26, 2026
@lin-snow lin-snow added this pull request to the merge queue May 26, 2026
@lin-snow
Copy link
Copy Markdown
Contributor Author

😡😡😡 what the fking merge queue doing....

@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to failed status checks May 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lgtm This PR has been approved by a maintainer size:XXL This PR changes 1000+ lines, ignoring generated files. web This relates to changes on the web.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants