Skip to content

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

Merged
lin-snow merged 15 commits into
langgenius:mainfrom
lin-snow:feat/cli-workspace-members
May 27, 2026
Merged

feat(openapi,cli): workspace switch + member management#36651
lin-snow merged 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-27 02:53:07.462020242 +0000
+++ /tmp/pyrefly_pr.txt	2026-05-27 02:52:55.195987879 +0000
@@ -555,7 +555,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]
@@ -2297,6 +2297,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.81% 45.73% -0.07%
Strict coverage 45.33% 45.26% -0.07%
Typed symbols 24,416 24,434 +18
Untyped symbols 29,191 29,300 +109
Modules 2733 2736 +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 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
@lin-snow lin-snow added this pull request to the merge queue May 27, 2026
@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to failed status checks May 27, 2026
lin-snow and others added 15 commits May 27, 2026 10:51
…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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
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 5ea510d to bbddb51 Compare May 27, 2026 02:51
@lin-snow lin-snow enabled auto-merge May 27, 2026 03:01
@lin-snow lin-snow added this pull request to the merge queue May 27, 2026
Merged via the queue into langgenius:main with commit 6e1e0d9 May 27, 2026
35 checks passed
@lin-snow lin-snow deleted the feat/cli-workspace-members branch May 27, 2026 03:20
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