feat(openapi,cli): workspace switch + member management#36651
Open
lin-snow wants to merge 15 commits into
Open
feat(openapi,cli): workspace switch + member management#36651lin-snow wants to merge 15 commits into
lin-snow wants to merge 15 commits into
Conversation
Contributor
Pyrefly Diffbase → 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]
|
Contributor
Pyrefly Type Coverage
|
d848820 to
e657d25
Compare
wylswz
reviewed
May 26, 2026
e657d25 to
5369a51
Compare
wylswz
reviewed
May 26, 2026
…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]>
- 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.
05ad531 to
ec37b8c
Compare
wylswz
approved these changes
May 26, 2026
Contributor
Author
|
😡😡😡 what the fking merge queue doing.... |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds workspace switching and member management to the bearer-authenticated
/openapi/v1surface, plus the matchingdifyctlcommands. 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/v1endpointsPOST/workspaces/{id}/switchGET/workspaces/{id}/memberspage/limit)POST/workspaces/{id}/membersPUT/workspaces/{id}/members/{member_id}/roleDELETE/workspaces/{id}/members/{member_id}A new
@require_workspace_role(*roles)decorator centralizes the gate. It returns 404 for non-members (parity withGET /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 endNew
difyctlcommandsdifyctl use workspacedifyctl get member--page/--limitpaginationdifyctl create memberdifyctl set memberdifyctl delete membercreate/set/delete memberaccept-o json|yaml|name|text;auth devices listalso gained--page/--limit.Included fix
require_workspace_roleand the member handlers now read the caller identity from the openapi auth ContextVar (try_get_auth_ctx()/get_auth_ctx()), the slotvalidate_beareractually publishes — notflask.g.auth_ctx, which production never writes. The earlierflask.gread made every role-gated request raiseRuntimeError→ 500 while unit tests stayed green (they seededgdirectly). A regression test now seeds onlyg, leaves the ContextVar empty, and asserts the gate still rejects — pinning the identity source.Test plan
api/tests/unit_tests/controllers/openapi/auth/test_role_gate.py(gate: 404 / 403 / pass, query scoping, wiring-bugRuntimeErrors) andtest_workspaces_members.py(endpoint behaviors).*.test.ts).curland the realdifyctlbinary — switch / list / invite / role-change / remove all return expected results.Checklist
make lint && make type-check(backend) andcd web && pnpm exec vp staged(frontend) to appease the lint gods