From a07b32274a5ed95cb3a6685e695a5cf11fae7765 Mon Sep 17 00:00:00 2001 From: GareArc Date: Mon, 27 Apr 2026 00:10:16 -0700 Subject: [PATCH] feat(api): add /openapi/v1/workspaces reads (Phase E.17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /openapi/v1/workspaces lists tenants the bearer's account is a member of. GET /openapi/v1/workspaces/ returns one workspace detail, member-gated (404 on non-member, never 403, so workspace IDs don't leak across tenants). Bearer-authed via @validate_bearer(accept=ACCEPT_USER_ANY). External SSO bearers (no account_id) get an empty list / 404 — same posture as GET /openapi/v1/account. Cookie-authed /console/api/workspaces stays in console for the dashboard SPA — different consumer, different auth model. No legacy /v1/ remount this phase. Plan: docs/superpowers/plans/2026-04-26-openapi-migration.md (in difyctl repo). --- api/controllers/openapi/__init__.py | 3 +- api/controllers/openapi/workspaces.py | 86 +++++++++++++++++++ .../controllers/openapi/test_workspaces.py | 57 ++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 api/controllers/openapi/workspaces.py create mode 100644 api/tests/unit_tests/controllers/openapi/test_workspaces.py diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index a5b30311f3..bb0829e20b 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -16,13 +16,14 @@ api = ExternalApi( openapi_ns = Namespace("openapi", description="User-scoped operations", path="/") -from . import account, index, oauth_device, oauth_device_sso +from . import account, index, oauth_device, oauth_device_sso, workspaces __all__ = [ "account", "index", "oauth_device", "oauth_device_sso", + "workspaces", ] api.add_namespace(openapi_ns) diff --git a/api/controllers/openapi/workspaces.py b/api/controllers/openapi/workspaces.py new file mode 100644 index 0000000000..173ebcbb57 --- /dev/null +++ b/api/controllers/openapi/workspaces.py @@ -0,0 +1,86 @@ +"""User-scoped workspace reads under /openapi/v1/workspaces. 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. +""" +from __future__ import annotations + +from flask import g +from flask_restx import Resource +from sqlalchemy import select +from werkzeug.exceptions import NotFound + +from controllers.openapi import openapi_ns +from extensions.ext_database import db +from libs.oauth_bearer import ( + ACCEPT_USER_ANY, + SubjectType, + validate_bearer, +) +from models import Tenant, TenantAccountJoin + + +@openapi_ns.route("/workspaces") +class WorkspacesApi(Resource): + @validate_bearer(accept=ACCEPT_USER_ANY) + def get(self): + ctx = g.auth_ctx + if ctx.subject_type != SubjectType.ACCOUNT or not ctx.account_id: + return {"workspaces": []}, 200 + + rows = db.session.execute( + select(Tenant, TenantAccountJoin) + .join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id) + .where(TenantAccountJoin.account_id == str(ctx.account_id)) + .order_by(Tenant.created_at.asc()) + ).all() + + return {"workspaces": [_workspace_summary(t, m) for t, m in rows]}, 200 + + +@openapi_ns.route("/workspaces/") +class WorkspaceByIdApi(Resource): + @validate_bearer(accept=ACCEPT_USER_ANY) + def get(self, workspace_id: str): + ctx = g.auth_ctx + # External SSO + missing account → never a member of anything; 404. + if ctx.subject_type != SubjectType.ACCOUNT or not ctx.account_id: + 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() + # 404 (not 403) on non-member so workspace IDs don't leak across tenants. + if row is None: + raise NotFound("workspace not found") + + tenant, membership = row + return _workspace_detail(tenant, membership), 200 + + +def _workspace_summary(tenant: Tenant, membership: TenantAccountJoin) -> dict: + return { + "id": str(tenant.id), + "name": tenant.name, + "role": getattr(membership, "role", ""), + "status": tenant.status, + "current": getattr(membership, "current", False), + } + + +def _workspace_detail(tenant: Tenant, membership: TenantAccountJoin) -> dict: + return { + "id": str(tenant.id), + "name": tenant.name, + "role": getattr(membership, "role", ""), + "status": tenant.status, + "current": getattr(membership, "current", False), + "created_at": tenant.created_at.isoformat() if tenant.created_at else None, + } diff --git a/api/tests/unit_tests/controllers/openapi/test_workspaces.py b/api/tests/unit_tests/controllers/openapi/test_workspaces.py new file mode 100644 index 0000000000..8e90bf27fe --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_workspaces.py @@ -0,0 +1,57 @@ +"""Phase E step 17: workspace reads at /openapi/v1/workspaces. Bearer-authed +list + member-gated detail. No legacy /v1/ equivalent — the cookie-authed +/console/api/workspaces is a separate consumer that stays in console. +""" +import builtins + +import pytest +from flask import Flask +from flask.views import MethodView + +from controllers.openapi import bp as openapi_bp +from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspacesApi + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +@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 test_workspaces_list_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/workspaces" in rules + + +def test_workspaces_list_dispatches_to_workspaces_api(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/workspaces") + assert openapi_app.view_functions[rule.endpoint].view_class is WorkspacesApi + assert "GET" in rule.methods + + +def test_workspace_by_id_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/workspaces/" in rules + + +def test_workspace_by_id_dispatches_to_correct_class(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/workspaces/") + assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceByIdApi + assert "GET" in rule.methods + + +def test_console_legacy_workspaces_route_not_remounted_on_openapi(openapi_app: Flask): + """Phase E only adds the bearer-authed mounts on /openapi/v1/. + The cookie-authed /console/api/workspaces stays where it is. + """ + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/console/api/workspaces" not in rules